1use serde::{Deserialize, Serialize};
2use serde_json::{Map, Value};
3use std::fmt;
4use std::ops::Deref;
5use std::str::FromStr;
6
7use crate::error::AtmError;
8use crate::schema::{AtmMessageId, InboxMessage, MessageEnvelope};
9use crate::types::{AgentName, IsoTimestamp, ModelName, PaneId, TaskId, TeamName};
10
11#[doc(hidden)]
12pub mod sealed {
13 pub trait Sealed {}
14}
15
16fn require_non_blank(
17 value: String,
18 subject: &str,
19 recovery: &'static str,
20) -> Result<String, AtmError> {
21 if value.trim().is_empty() {
22 return Err(
23 AtmError::validation(format!("{subject} must not be blank")).with_recovery(recovery)
24 );
25 }
26 Ok(value)
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
30#[serde(transparent)]
31pub struct MessageKey(String);
32
33impl MessageKey {
34 pub fn new(value: impl Into<String>) -> Result<Self, AtmError> {
35 require_non_blank(
36 value.into(),
37 "message key",
38 "Populate a stable ATM message key before calling the storage contract.",
39 )
40 .map(Self)
41 }
42
43 pub fn into_inner(self) -> String {
44 self.0
45 }
46
47 pub fn as_str(&self) -> &str {
48 &self.0
49 }
50
51 pub fn as_atm_message_id(&self) -> Result<AtmMessageId, AtmError> {
52 let raw = self.as_str().strip_prefix("atm:").unwrap_or(self.as_str());
53 raw.parse::<AtmMessageId>()
54 .map_err(|error| AtmError::validation(format!("message key parse failed: {error}")))
55 }
56
57 fn from_atm_message_id(value: AtmMessageId) -> Self {
58 let mut key = String::with_capacity(4 + value.to_string().len());
59 key.push_str("atm:");
60 key.push_str(&value.to_string());
61 Self(key)
62 }
63}
64
65impl From<AtmMessageId> for MessageKey {
66 fn from(value: AtmMessageId) -> Self {
67 Self::from_atm_message_id(value)
68 }
69}
70
71impl FromStr for MessageKey {
72 type Err = AtmError;
73
74 fn from_str(value: &str) -> Result<Self, Self::Err> {
75 Self::new(value)
76 }
77}
78
79impl fmt::Display for MessageKey {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 f.write_str(self.as_str())
82 }
83}
84
85impl AsRef<str> for MessageKey {
86 fn as_ref(&self) -> &str {
87 self.as_str()
88 }
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
92#[serde(transparent)]
93pub struct TaskState(String);
94
95impl TaskState {
96 pub fn new(value: impl Into<String>) -> Result<Self, AtmError> {
97 require_non_blank(
98 value.into(),
99 "task state",
100 "Populate a non-empty task state before calling the storage contract.",
101 )
102 .map(Self)
103 }
104}
105
106impl AsRef<str> for TaskState {
107 fn as_ref(&self) -> &str {
108 &self.0
109 }
110}
111
112impl Deref for TaskState {
113 type Target = str;
114
115 fn deref(&self) -> &Self::Target {
116 &self.0
117 }
118}
119
120impl fmt::Display for TaskState {
121 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122 f.write_str(self.as_ref())
123 }
124}
125
126impl FromStr for TaskState {
127 type Err = AtmError;
128
129 fn from_str(value: &str) -> Result<Self, Self::Err> {
130 Self::new(value)
131 }
132}
133
134impl PartialEq<&str> for TaskState {
135 fn eq(&self, other: &&str) -> bool {
136 self.as_ref() == *other
137 }
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
141#[serde(transparent)]
142pub struct AckTransition(String);
143
144impl AckTransition {
145 pub fn new(value: impl Into<String>) -> Result<Self, AtmError> {
146 require_non_blank(
147 value.into(),
148 "ack transition",
149 "Populate a non-empty ack transition before calling the storage contract.",
150 )
151 .map(Self)
152 }
153}
154
155impl AsRef<str> for AckTransition {
156 fn as_ref(&self) -> &str {
157 &self.0
158 }
159}
160
161impl Deref for AckTransition {
162 type Target = str;
163
164 fn deref(&self) -> &Self::Target {
165 &self.0
166 }
167}
168
169impl fmt::Display for AckTransition {
170 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171 f.write_str(self.as_ref())
172 }
173}
174
175impl FromStr for AckTransition {
176 type Err = AtmError;
177
178 fn from_str(value: &str) -> Result<Self, Self::Err> {
179 Self::new(value)
180 }
181}
182
183#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
184#[serde(rename_all = "snake_case")]
185pub enum BuiltInNudgeTemplateKind {
186 Delivery,
187 DeliveryAck,
188 DeliveryTask,
189 DeliveryTaskAck,
190 Acknowledge,
191 AcknowledgeTask,
192}
193
194impl BuiltInNudgeTemplateKind {
195 pub const fn as_str(self) -> &'static str {
196 match self {
197 Self::Delivery => "delivery",
198 Self::DeliveryAck => "delivery_ack",
199 Self::DeliveryTask => "delivery_task",
200 Self::DeliveryTaskAck => "delivery_task_ack",
201 Self::Acknowledge => "acknowledge",
202 Self::AcknowledgeTask => "acknowledge_task",
203 }
204 }
205}
206
207impl fmt::Display for BuiltInNudgeTemplateKind {
208 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209 f.write_str(self.as_str())
210 }
211}
212
213impl FromStr for BuiltInNudgeTemplateKind {
214 type Err = AtmError;
215
216 fn from_str(value: &str) -> Result<Self, Self::Err> {
217 match value {
218 "delivery" => Ok(Self::Delivery),
219 "delivery_ack" => Ok(Self::DeliveryAck),
220 "delivery_task" => Ok(Self::DeliveryTask),
221 "delivery_task_ack" => Ok(Self::DeliveryTaskAck),
222 "acknowledge" => Ok(Self::Acknowledge),
223 "acknowledge_task" => Ok(Self::AcknowledgeTask),
224 other => Err(AtmError::validation(format!(
225 "unsupported built-in nudge template kind `{other}`"
226 ))
227 .with_recovery(
228 "Use one of delivery, delivery_ack, delivery_task, delivery_task_ack, acknowledge, or acknowledge_task.",
229 )),
230 }
231 }
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
235#[serde(rename_all = "kebab-case")]
236pub enum TeamNudgeTemplateOverrideMode {
237 Override { template_body: String },
238 Disabled,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
242pub struct TeamNudgeTemplateOverrideRow {
243 pub team_name: TeamName,
244 pub kind: BuiltInNudgeTemplateKind,
245 pub mode: TeamNudgeTemplateOverrideMode,
246 pub updated_at: IsoTimestamp,
247}
248
249impl TeamNudgeTemplateOverrideRow {
250 pub fn template_body(&self) -> Option<&str> {
251 match &self.mode {
252 TeamNudgeTemplateOverrideMode::Override { template_body } => Some(template_body),
253 TeamNudgeTemplateOverrideMode::Disabled => None,
254 }
255 }
256
257 pub fn is_disabled(&self) -> bool {
258 matches!(self.mode, TeamNudgeTemplateOverrideMode::Disabled)
259 }
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "snake_case")]
264pub enum AckRequirementState {
265 NotRequired,
266 RequiredPending,
267 RequiredAcknowledged,
268}
269
270pub fn derive_ack_requirement(message: &InboxMessage) -> AckRequirementState {
271 if !message.requires_ack {
272 AckRequirementState::NotRequired
273 } else if message.acknowledged_at.is_some() {
274 AckRequirementState::RequiredAcknowledged
275 } else {
276 AckRequirementState::RequiredPending
277 }
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
281pub struct Message {
282 pub team: TeamName,
283 pub agent: AgentName,
284 pub message_key: MessageKey,
285 pub envelope: MessageEnvelope,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
289pub struct MailMessageState {
290 pub team: TeamName,
291 pub agent: AgentName,
292 pub actor: AgentName,
293 pub message_key: MessageKey,
294 pub read: bool,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub pending_ack_at: Option<IsoTimestamp>,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub acknowledged_at: Option<IsoTimestamp>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub expires_at: Option<IsoTimestamp>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub deleted_at: Option<IsoTimestamp>,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub updated_at: Option<IsoTimestamp>,
305}
306
307#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
310#[serde(transparent)]
311pub struct MessageFingerprint(String);
312
313impl MessageFingerprint {
314 pub fn new(value: impl Into<String>) -> Self {
315 Self(value.into())
316 }
317
318 pub fn as_str(&self) -> &str {
319 &self.0
320 }
321}
322
323impl fmt::Display for MessageFingerprint {
324 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325 f.write_str(self.as_str())
326 }
327}
328
329impl From<String> for MessageFingerprint {
330 fn from(value: String) -> Self {
331 Self::new(value)
332 }
333}
334
335impl AsRef<str> for MessageFingerprint {
336 fn as_ref(&self) -> &str {
337 self.as_str()
338 }
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
342pub struct MessageQuery {
343 pub team: TeamName,
344 pub agent: AgentName,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub sender: Option<AgentName>,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub task_id: Option<TaskId>,
349 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub limit: Option<usize>,
351}
352
353#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
354#[serde(rename_all = "kebab-case")]
355pub enum RosterMemberKind {
356 Permanent,
357 Ephemeral,
358}
359
360#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
361#[serde(rename_all = "kebab-case")]
362pub enum RosterHarness {
363 ClaudeCode,
364 CodexCli,
365 GeminiCli,
366 Opencode,
367}
368
369#[derive(Debug, Clone, PartialEq, Eq)]
370pub enum AgentType {
371 GeneralPurpose,
372 Plan,
373 Lead,
374 Qa,
375 Worker,
376 Unknown(String),
377}
378
379impl Default for AgentType {
380 fn default() -> Self {
381 Self::Unknown(String::new())
382 }
383}
384
385impl From<String> for AgentType {
386 fn from(value: String) -> Self {
387 match value.as_str() {
388 "general-purpose" => Self::GeneralPurpose,
389 "plan" => Self::Plan,
390 "lead" => Self::Lead,
391 "qa" => Self::Qa,
392 "worker" => Self::Worker,
393 _ => Self::Unknown(value),
394 }
395 }
396}
397
398impl AgentType {
399 pub fn as_str(&self) -> &str {
400 match self {
401 Self::GeneralPurpose => "general-purpose",
402 Self::Plan => "plan",
403 Self::Lead => "lead",
404 Self::Qa => "qa",
405 Self::Worker => "worker",
406 Self::Unknown(value) => value,
407 }
408 }
409}
410
411impl From<AgentType> for String {
412 fn from(value: AgentType) -> Self {
413 value.as_str().to_string()
414 }
415}
416
417impl Serialize for AgentType {
418 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
419 where
420 S: serde::Serializer,
421 {
422 serializer.serialize_str(self.as_str())
423 }
424}
425
426impl fmt::Display for AgentType {
427 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
428 f.write_str(self.as_str())
429 }
430}
431
432impl<'de> Deserialize<'de> for AgentType {
433 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
434 where
435 D: serde::Deserializer<'de>,
436 {
437 Ok(Self::from(String::deserialize(deserializer)?))
438 }
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
442pub struct RosterMember {
443 pub team_name: TeamName,
444 pub agent_name: AgentName,
445 pub member_kind: RosterMemberKind,
446 pub harness: RosterHarness,
447 #[serde(default)]
448 pub agent_type: AgentType,
449 #[serde(default)]
450 pub model: ModelName,
451 #[serde(default, skip_serializing_if = "Option::is_none")]
452 pub recipient_pane_id: Option<PaneId>,
453 #[serde(default)]
454 pub metadata_json: Map<String, Value>,
455}
456
457#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
458pub struct RosterSnapshot {
459 pub team_name: TeamName,
460 pub members: Vec<RosterMember>,
461 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub refreshed_at: Option<IsoTimestamp>,
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
466pub struct MessageReceivedEvent {
467 pub team: TeamName,
468 pub agent: AgentName,
469 pub message_key: MessageKey,
470 pub timestamp: IsoTimestamp,
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
474pub struct RosterChangedEvent {
475 pub team: TeamName,
476 pub member_count: usize,
477 pub timestamp: IsoTimestamp,
478}
479
480pub trait MessageStore {
481 fn save_message(&self, message: &Message) -> Result<(), AtmError>;
482 fn load_message(&self, key: &MessageKey) -> Result<Option<Message>, AtmError>;
483 fn list_messages(&self, query: &MessageQuery) -> Result<Vec<Message>, AtmError>;
484 fn delete_message(&self, key: &MessageKey) -> Result<(), AtmError>;
485}
486
487pub trait RosterStore {
488 fn load_roster(&self, team: &TeamName) -> Result<RosterSnapshot, AtmError>;
489 fn save_roster(&self, roster: &RosterSnapshot) -> Result<(), AtmError>;
490 fn list_teams(&self) -> Result<Vec<TeamName>, AtmError>;
491}
492
493pub trait StorageNotifier {
494 fn message_received(&self, event: &MessageReceivedEvent) -> Result<(), AtmError>;
495 fn roster_changed(&self, event: &RosterChangedEvent) -> Result<(), AtmError>;
496}
497
498pub trait NudgeTemplateOverrideStore: sealed::Sealed + Send + Sync {
499 fn load_template_override(
500 &self,
501 team: &TeamName,
502 kind: BuiltInNudgeTemplateKind,
503 ) -> Result<Option<TeamNudgeTemplateOverrideRow>, AtmError>;
504
505 fn save_template_override(
506 &self,
507 team: &TeamName,
508 kind: BuiltInNudgeTemplateKind,
509 template_body: &str,
510 ) -> Result<TeamNudgeTemplateOverrideRow, AtmError>;
511
512 fn disable_template_override(
513 &self,
514 team: &TeamName,
515 kind: BuiltInNudgeTemplateKind,
516 ) -> Result<TeamNudgeTemplateOverrideRow, AtmError>;
517
518 fn clear_template_override(
519 &self,
520 team: &TeamName,
521 kind: BuiltInNudgeTemplateKind,
522 ) -> Result<bool, AtmError>;
523}
524
525#[cfg(test)]
526mod tests {
527 use super::{
528 AckRequirementState, BuiltInNudgeTemplateKind, Message, MessageKey, MessageQuery,
529 MessageReceivedEvent, MessageStore, NudgeTemplateOverrideStore, RosterChangedEvent,
530 RosterHarness, RosterMember, RosterMemberKind, RosterSnapshot, RosterStore,
531 StorageNotifier, TeamNudgeTemplateOverrideMode, TeamNudgeTemplateOverrideRow,
532 derive_ack_requirement, sealed,
533 };
534 use crate::ROLE_WORKER;
535 use crate::error::AtmError;
536 use crate::schema::MessageEnvelope;
537 use crate::types::{AgentName, IsoTimestamp, ModelName, TeamName};
538 use chrono::Utc;
539 use serde_json::Map;
540
541 #[derive(Default)]
542 struct DummyStore;
543
544 #[derive(Default)]
545 struct DummyNudgeTemplateOverrideStore;
546
547 impl MessageStore for DummyStore {
548 fn save_message(&self, _message: &Message) -> Result<(), AtmError> {
549 Ok(())
550 }
551
552 fn load_message(&self, _key: &MessageKey) -> Result<Option<Message>, AtmError> {
553 Ok(None)
554 }
555
556 fn list_messages(&self, _query: &MessageQuery) -> Result<Vec<Message>, AtmError> {
557 Ok(Vec::new())
558 }
559
560 fn delete_message(&self, _key: &MessageKey) -> Result<(), AtmError> {
561 Ok(())
562 }
563 }
564
565 impl RosterStore for DummyStore {
566 fn load_roster(&self, team: &TeamName) -> Result<RosterSnapshot, AtmError> {
567 Ok(RosterSnapshot {
568 team_name: team.clone(),
569 members: Vec::new(),
570 refreshed_at: None,
571 })
572 }
573
574 fn save_roster(&self, _roster: &RosterSnapshot) -> Result<(), AtmError> {
575 Ok(())
576 }
577
578 fn list_teams(&self) -> Result<Vec<TeamName>, AtmError> {
579 Ok(Vec::new())
580 }
581 }
582
583 impl StorageNotifier for DummyStore {
584 fn message_received(&self, _event: &MessageReceivedEvent) -> Result<(), AtmError> {
585 Ok(())
586 }
587
588 fn roster_changed(&self, _event: &RosterChangedEvent) -> Result<(), AtmError> {
589 Ok(())
590 }
591 }
592
593 impl sealed::Sealed for DummyNudgeTemplateOverrideStore {}
594
595 impl NudgeTemplateOverrideStore for DummyNudgeTemplateOverrideStore {
596 fn load_template_override(
597 &self,
598 _team: &TeamName,
599 _kind: BuiltInNudgeTemplateKind,
600 ) -> Result<Option<TeamNudgeTemplateOverrideRow>, AtmError> {
601 Ok(None)
602 }
603
604 fn save_template_override(
605 &self,
606 team: &TeamName,
607 kind: BuiltInNudgeTemplateKind,
608 template_body: &str,
609 ) -> Result<TeamNudgeTemplateOverrideRow, AtmError> {
610 Ok(TeamNudgeTemplateOverrideRow {
611 team_name: team.clone(),
612 kind,
613 mode: TeamNudgeTemplateOverrideMode::Override {
614 template_body: template_body.to_string(),
615 },
616 updated_at: IsoTimestamp::from_datetime(Utc::now()),
617 })
618 }
619
620 fn disable_template_override(
621 &self,
622 team: &TeamName,
623 kind: BuiltInNudgeTemplateKind,
624 ) -> Result<TeamNudgeTemplateOverrideRow, AtmError> {
625 Ok(TeamNudgeTemplateOverrideRow {
626 team_name: team.clone(),
627 kind,
628 mode: TeamNudgeTemplateOverrideMode::Disabled,
629 updated_at: IsoTimestamp::from_datetime(Utc::now()),
630 })
631 }
632
633 fn clear_template_override(
634 &self,
635 _team: &TeamName,
636 _kind: BuiltInNudgeTemplateKind,
637 ) -> Result<bool, AtmError> {
638 Ok(true)
639 }
640 }
641
642 #[test]
643 fn storage_traits_are_object_safe() {
644 let store = DummyStore;
645 let message_store: &dyn MessageStore = &store;
646 let roster_store: &dyn RosterStore = &store;
647 let notifier: &dyn StorageNotifier = &store;
648 let override_store: &dyn NudgeTemplateOverrideStore = &DummyNudgeTemplateOverrideStore;
649
650 let team: TeamName = "test-team".parse().expect("team");
651 let agent: AgentName = ROLE_WORKER.parse().expect("agent");
652 let key = MessageKey::new("atm:test-1").expect("key");
653
654 let message = Message {
655 team: team.clone(),
656 agent: agent.clone(),
657 message_key: key.clone(),
658 envelope: MessageEnvelope {
659 from: agent.clone(),
660 text: "hello".to_string(),
661 timestamp: IsoTimestamp::from_datetime(Utc::now()),
662 read: false,
663 source_team: Some(team.clone()),
664 summary: None,
665 message_id: None,
666 requires_ack: false,
667 pending_ack_at: None,
668 acknowledged_at: None,
669 acknowledges_message_id: None,
670 parent_message_id: None,
671 thread_mode: None,
672 expires_at: None,
673 task_id: None,
674 extra: Map::new(),
675 },
676 };
677 let roster = RosterSnapshot {
678 team_name: team.clone(),
679 members: vec![RosterMember {
680 team_name: team.clone(),
681 agent_name: agent.clone(),
682 member_kind: RosterMemberKind::Permanent,
683 harness: RosterHarness::ClaudeCode,
684 agent_type: super::AgentType::Worker,
685 model: ModelName::default(),
686 recipient_pane_id: None,
687 metadata_json: Map::new(),
688 }],
689 refreshed_at: None,
690 };
691
692 message_store.save_message(&message).expect("save message");
693 assert!(message_store.load_message(&key).expect("load").is_none());
694 assert!(
695 message_store
696 .list_messages(&MessageQuery {
697 team: team.clone(),
698 agent: agent.clone(),
699 sender: None,
700 task_id: None,
701 limit: Some(5),
702 })
703 .expect("list")
704 .is_empty()
705 );
706 message_store.delete_message(&key).expect("delete");
707
708 roster_store.save_roster(&roster).expect("save roster");
709 assert_eq!(
710 roster_store
711 .load_roster(&team)
712 .expect("load roster")
713 .team_name,
714 team
715 );
716 assert!(roster_store.list_teams().expect("list teams").is_empty());
717
718 notifier
719 .message_received(&MessageReceivedEvent {
720 team: team.clone(),
721 agent: agent.clone(),
722 message_key: key,
723 timestamp: IsoTimestamp::from_datetime(Utc::now()),
724 })
725 .expect("message notification");
726 notifier
727 .roster_changed(&RosterChangedEvent {
728 team,
729 member_count: 1,
730 timestamp: IsoTimestamp::from_datetime(Utc::now()),
731 })
732 .expect("roster notification");
733
734 let override_row = override_store
735 .save_template_override(
736 &"test-team".parse().expect("team"),
737 BuiltInNudgeTemplateKind::DeliveryAck,
738 "<atm/>",
739 )
740 .expect("save override");
741 assert_eq!(override_row.kind, BuiltInNudgeTemplateKind::DeliveryAck);
742 }
743
744 #[test]
745 fn derive_ack_requirement_ignores_task_id_and_uses_only_requires_ack_and_acknowledged_at() {
746 let base = MessageEnvelope {
747 from: "sender".parse().expect("agent"),
748 text: "hello".to_string(),
749 timestamp: IsoTimestamp::from_datetime(Utc::now()),
750 read: false,
751 source_team: Some("test-team".parse().expect("team")),
752 summary: None,
753 message_id: None,
754 requires_ack: false,
755 pending_ack_at: None,
756 acknowledged_at: None,
757 acknowledges_message_id: None,
758 parent_message_id: None,
759 thread_mode: None,
760 expires_at: None,
761 task_id: Some("AD.99".parse().expect("task")),
762 extra: Map::new(),
763 };
764
765 assert_eq!(
766 derive_ack_requirement(&base),
767 AckRequirementState::NotRequired
768 );
769
770 let mut pending = base.clone();
771 pending.requires_ack = true;
772 assert_eq!(
773 derive_ack_requirement(&pending),
774 AckRequirementState::RequiredPending
775 );
776
777 pending.acknowledged_at = Some(IsoTimestamp::from_datetime(Utc::now()));
778 assert_eq!(
779 derive_ack_requirement(&pending),
780 AckRequirementState::RequiredAcknowledged
781 );
782 }
783}