Skip to main content

rustant_core/channels/
auto_reply.rs

1//! Auto-Reply Engine for channel intelligence.
2//!
3//! Generates and manages automatic responses to classified channel messages.
4//! Respects the configured `AutoReplyMode` and gates all outgoing replies
5//! through the `SafetyGuardian` approval system.
6//!
7//! # Reply Flow
8//!
9//! 1. Receive a `ClassifiedMessage` with a suggested action
10//! 2. Generate a reply draft (via LLM or template)
11//! 3. Apply safety gating based on `AutoReplyMode` and priority
12//! 4. Either send, queue for approval, or store as draft
13//! 5. Record outcome for learning feedback
14
15use super::intelligence::{ClassifiedMessage, SuggestedAction};
16use crate::config::{AutoReplyMode, IntelligenceConfig, MessagePriority};
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19use uuid::Uuid;
20
21/// Status of a pending auto-reply in its lifecycle.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub enum ReplyStatus {
24    /// LLM is generating the response.
25    Drafting,
26    /// Queued for user approval.
27    PendingApproval,
28    /// User approved, ready to send.
29    Approved,
30    /// Successfully delivered to channel.
31    Sent,
32    /// User rejected the reply.
33    Rejected,
34    /// Timed out waiting for approval.
35    Expired,
36}
37
38/// A pending auto-reply awaiting approval or delivery.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct PendingReply {
41    /// Unique identifier for this reply.
42    pub id: Uuid,
43    /// The channel through which the reply will be sent.
44    pub channel_name: String,
45    /// The sender to reply to.
46    pub recipient: String,
47    /// The original message summary.
48    pub original_summary: String,
49    /// The original message priority.
50    pub priority: MessagePriority,
51    /// The generated draft response text.
52    pub draft_response: String,
53    /// Current status of the reply.
54    pub status: ReplyStatus,
55    /// When the reply was created.
56    pub created_at: DateTime<Utc>,
57    /// When the reply was last updated.
58    pub updated_at: DateTime<Utc>,
59    /// Reasoning for the auto-reply decision (for audit).
60    pub reasoning: String,
61}
62
63impl PendingReply {
64    /// Create a new pending reply in drafting state.
65    pub fn new(
66        channel_name: impl Into<String>,
67        recipient: impl Into<String>,
68        original_summary: impl Into<String>,
69        priority: MessagePriority,
70    ) -> Self {
71        let now = Utc::now();
72        Self {
73            id: Uuid::new_v4(),
74            channel_name: channel_name.into(),
75            recipient: recipient.into(),
76            original_summary: original_summary.into(),
77            priority,
78            draft_response: String::new(),
79            status: ReplyStatus::Drafting,
80            created_at: now,
81            updated_at: now,
82            reasoning: String::new(),
83        }
84    }
85
86    /// Set the draft response text.
87    pub fn with_draft(mut self, draft: impl Into<String>) -> Self {
88        self.draft_response = draft.into();
89        self.status = ReplyStatus::PendingApproval;
90        self.updated_at = Utc::now();
91        self
92    }
93
94    /// Set the reasoning for the auto-reply decision.
95    pub fn with_reasoning(mut self, reasoning: impl Into<String>) -> Self {
96        self.reasoning = reasoning.into();
97        self
98    }
99
100    /// Attempt to approve the reply. Only valid from `PendingApproval` state.
101    pub fn try_approve(&mut self) -> Result<(), &'static str> {
102        match self.status {
103            ReplyStatus::PendingApproval => {
104                self.status = ReplyStatus::Approved;
105                self.updated_at = Utc::now();
106                Ok(())
107            }
108            _ => Err("can only approve a reply in PendingApproval state"),
109        }
110    }
111
112    /// Attempt to reject the reply. Only valid from `PendingApproval` or `Drafting` state.
113    pub fn try_reject(&mut self) -> Result<(), &'static str> {
114        match self.status {
115            ReplyStatus::PendingApproval | ReplyStatus::Drafting => {
116                self.status = ReplyStatus::Rejected;
117                self.updated_at = Utc::now();
118                Ok(())
119            }
120            _ => Err("can only reject a reply in PendingApproval or Drafting state"),
121        }
122    }
123
124    /// Attempt to mark the reply as sent. Only valid from `Approved` state.
125    pub fn try_mark_sent(&mut self) -> Result<(), &'static str> {
126        match self.status {
127            ReplyStatus::Approved => {
128                self.status = ReplyStatus::Sent;
129                self.updated_at = Utc::now();
130                Ok(())
131            }
132            _ => Err("can only mark as sent a reply in Approved state"),
133        }
134    }
135
136    /// Attempt to expire the reply. Only valid from `PendingApproval` or `Drafting` state.
137    pub fn try_expire(&mut self) -> Result<(), &'static str> {
138        match self.status {
139            ReplyStatus::PendingApproval | ReplyStatus::Drafting => {
140                self.status = ReplyStatus::Expired;
141                self.updated_at = Utc::now();
142                Ok(())
143            }
144            _ => Err("can only expire a reply in PendingApproval or Drafting state"),
145        }
146    }
147
148    /// Check if the reply is actionable (can be sent or needs approval).
149    pub fn is_actionable(&self) -> bool {
150        matches!(
151            self.status,
152            ReplyStatus::PendingApproval | ReplyStatus::Approved
153        )
154    }
155}
156
157/// The auto-reply engine manages reply generation and lifecycle.
158///
159/// It determines whether to auto-send, queue for approval, or draft-only
160/// based on the `AutoReplyMode` and the message priority.
161///
162/// **Important**: Rate limiting is per-instance. The system should use a single
163/// shared `AutoReplyEngine` instance to enforce rate limits correctly. Creating
164/// multiple instances would bypass the per-channel rate limiting.
165pub struct AutoReplyEngine {
166    /// Per-channel intelligence configuration defaults.
167    config: IntelligenceConfig,
168    /// Queue of pending replies.
169    pending_replies: Vec<PendingReply>,
170    /// Timestamps of sent replies for time-based windowed rate limiting.
171    reply_timestamps: std::collections::VecDeque<DateTime<Utc>>,
172    /// Maximum replies per hour per channel (rate limit).
173    max_replies_per_hour: usize,
174    /// Maximum character length for draft reply content. Replies exceeding
175    /// this limit are truncated with a "..." suffix.
176    max_reply_length: usize,
177}
178
179impl AutoReplyEngine {
180    /// Create a new auto-reply engine with the given intelligence config.
181    pub fn new(config: IntelligenceConfig) -> Self {
182        let max_replies = config.max_reply_tokens / 100; // rough heuristic
183        Self {
184            config,
185            pending_replies: Vec::new(),
186            reply_timestamps: std::collections::VecDeque::new(),
187            max_replies_per_hour: max_replies.max(10),
188            max_reply_length: 500,
189        }
190    }
191
192    /// Check if the rate limit has been reached within the sliding 1-hour window.
193    fn is_rate_limited(&mut self) -> bool {
194        let cutoff = Utc::now() - chrono::Duration::hours(1);
195        while self.reply_timestamps.front().is_some_and(|t| *t < cutoff) {
196            self.reply_timestamps.pop_front();
197        }
198        self.reply_timestamps.len() >= self.max_replies_per_hour
199    }
200
201    /// Truncate draft reply content to `max_reply_length` characters.
202    ///
203    /// If the content exceeds the limit, it is truncated at a character
204    /// boundary and a "..." suffix is appended.
205    fn truncate_draft(&self, draft: &str) -> String {
206        if draft.chars().count() > self.max_reply_length {
207            let truncated: String = draft.chars().take(self.max_reply_length).collect();
208            format!("{}...", truncated)
209        } else {
210            draft.to_string()
211        }
212    }
213
214    /// Process a classified message and determine the reply action.
215    ///
216    /// Returns `Some(PendingReply)` if a reply should be generated,
217    /// or `None` if no reply is needed.
218    pub fn process_classified(
219        &mut self,
220        classified: &ClassifiedMessage,
221        channel_name: &str,
222    ) -> Option<PendingReply> {
223        // Check rate limit (sliding 1-hour window)
224        if self.is_rate_limited() {
225            return None;
226        }
227
228        let channel_config = self.config.for_channel(channel_name);
229
230        match &classified.suggested_action {
231            SuggestedAction::AutoReply => {
232                let mut reply =
233                    self.create_reply(classified, channel_name, &channel_config.auto_reply);
234                reply.draft_response = self.truncate_draft(&reply.draft_response);
235                Some(reply)
236            }
237            SuggestedAction::DraftReply => {
238                let mut reply =
239                    self.create_reply(classified, channel_name, &AutoReplyMode::DraftOnly);
240                reply.draft_response = self.truncate_draft(&reply.draft_response);
241                reply.status = ReplyStatus::PendingApproval;
242                Some(reply)
243            }
244            _ => None,
245        }
246    }
247
248    /// Create a pending reply for the classified message.
249    fn create_reply(
250        &self,
251        classified: &ClassifiedMessage,
252        channel_name: &str,
253        mode: &AutoReplyMode,
254    ) -> PendingReply {
255        let recipient = classified
256            .original
257            .sender
258            .display_name
259            .clone()
260            .unwrap_or_else(|| classified.original.sender.id.clone());
261
262        let original_summary = match &classified.original.content {
263            super::types::MessageContent::Text { text } => {
264                if text.chars().count() > 100 {
265                    let truncated: String = text.chars().take(100).collect();
266                    format!("{}...", truncated)
267                } else {
268                    text.clone()
269                }
270            }
271            _ => format!("{:?}", classified.message_type),
272        };
273
274        let status = match (mode, &classified.priority) {
275            // FullAuto + Low/Normal -> auto-approve (will be sent immediately)
276            (AutoReplyMode::FullAuto, MessagePriority::Low)
277            | (AutoReplyMode::FullAuto, MessagePriority::Normal) => ReplyStatus::Approved,
278            // FullAuto + High/Urgent -> needs approval
279            (AutoReplyMode::FullAuto, _) => ReplyStatus::PendingApproval,
280            // AutoWithApproval -> always needs approval
281            (AutoReplyMode::AutoWithApproval, _) => ReplyStatus::PendingApproval,
282            // DraftOnly -> just a draft
283            (AutoReplyMode::DraftOnly, _) => ReplyStatus::PendingApproval,
284            // Disabled -> shouldn't reach here, but treat as draft
285            (AutoReplyMode::Disabled, _) => ReplyStatus::PendingApproval,
286        };
287
288        let reasoning = format!(
289            "Auto-reply mode={:?}, priority={:?}, type={:?}, classification_confidence={:.2}",
290            mode, classified.priority, classified.message_type, classified.confidence,
291        );
292
293        PendingReply {
294            id: Uuid::new_v4(),
295            channel_name: channel_name.to_string(),
296            recipient,
297            original_summary,
298            priority: classified.priority,
299            draft_response: String::new(), // Will be filled by LLM
300            status,
301            created_at: Utc::now(),
302            updated_at: Utc::now(),
303            reasoning,
304        }
305    }
306
307    /// Add a reply to the pending queue. The draft response is truncated
308    /// to `max_reply_length` if it exceeds the limit.
309    pub fn enqueue(&mut self, mut reply: PendingReply) {
310        reply.draft_response = self.truncate_draft(&reply.draft_response);
311        self.pending_replies.push(reply);
312    }
313
314    /// Get all pending replies that need approval.
315    pub fn pending_approval(&self) -> Vec<&PendingReply> {
316        self.pending_replies
317            .iter()
318            .filter(|r| r.status == ReplyStatus::PendingApproval)
319            .collect()
320    }
321
322    /// Get all approved replies ready to send.
323    pub fn ready_to_send(&self) -> Vec<&PendingReply> {
324        self.pending_replies
325            .iter()
326            .filter(|r| r.status == ReplyStatus::Approved)
327            .collect()
328    }
329
330    /// Approve a pending reply by ID. Returns false if not found or invalid state.
331    pub fn approve_reply(&mut self, id: Uuid) -> bool {
332        if let Some(reply) = self.pending_replies.iter_mut().find(|r| r.id == id) {
333            reply.try_approve().is_ok()
334        } else {
335            false
336        }
337    }
338
339    /// Reject a pending reply by ID. Returns false if not found or invalid state.
340    pub fn reject_reply(&mut self, id: Uuid) -> bool {
341        if let Some(reply) = self.pending_replies.iter_mut().find(|r| r.id == id) {
342            reply.try_reject().is_ok()
343        } else {
344            false
345        }
346    }
347
348    /// Mark a reply as sent and record the timestamp for rate limiting. Returns false if not found or invalid state.
349    pub fn mark_sent(&mut self, id: Uuid) -> bool {
350        if let Some(reply) = self.pending_replies.iter_mut().find(|r| r.id == id) {
351            if reply.try_mark_sent().is_ok() {
352                self.reply_timestamps.push_back(Utc::now());
353                true
354            } else {
355                false
356            }
357        } else {
358            false
359        }
360    }
361
362    /// Remove all completed (sent, rejected, expired) replies from the queue.
363    pub fn cleanup_completed(&mut self) -> usize {
364        let before = self.pending_replies.len();
365        self.pending_replies.retain(|r| {
366            !matches!(
367                r.status,
368                ReplyStatus::Sent | ReplyStatus::Rejected | ReplyStatus::Expired
369            )
370        });
371        before - self.pending_replies.len()
372    }
373
374    /// Get the total number of pending replies.
375    pub fn pending_count(&self) -> usize {
376        self.pending_replies.len()
377    }
378
379    /// Get the number of replies sent within the current 1-hour window.
380    pub fn sent_count(&self) -> usize {
381        let cutoff = Utc::now() - chrono::Duration::hours(1);
382        self.reply_timestamps
383            .iter()
384            .filter(|t| **t >= cutoff)
385            .count()
386    }
387
388    /// Reset the rate limit by clearing all timestamps. Kept for backward compatibility.
389    pub fn reset_rate_limit(&mut self) {
390        self.reply_timestamps.clear();
391    }
392
393    /// Expire all pending replies older than the given duration.
394    pub fn expire_old_replies(&mut self, max_age_secs: i64) -> usize {
395        let cutoff = Utc::now() - chrono::Duration::seconds(max_age_secs);
396        let mut expired = 0;
397        for reply in &mut self.pending_replies {
398            if reply.created_at < cutoff && reply.try_expire().is_ok() {
399                expired += 1;
400            }
401        }
402        expired
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use crate::channels::intelligence::{ClassifiedMessage, MessageType, SuggestedAction};
410    use crate::channels::types::{
411        ChannelMessage, ChannelType, ChannelUser, MessageContent, MessageId,
412    };
413    use crate::config::{IntelligenceConfig, MessagePriority};
414    use std::collections::HashMap;
415
416    fn make_classified(
417        text: &str,
418        priority: MessagePriority,
419        msg_type: MessageType,
420        action: SuggestedAction,
421    ) -> ClassifiedMessage {
422        let msg = ChannelMessage {
423            id: MessageId::random(),
424            channel_type: ChannelType::Slack,
425            channel_id: "C123".to_string(),
426            sender: ChannelUser::new("alice", ChannelType::Slack).with_name("Alice"),
427            content: MessageContent::Text {
428                text: text.to_string(),
429            },
430            timestamp: Utc::now(),
431            reply_to: None,
432            thread_id: None,
433            metadata: HashMap::new(),
434        };
435        ClassifiedMessage {
436            original: msg,
437            priority,
438            message_type: msg_type,
439            suggested_action: action,
440            confidence: 0.85,
441            reasoning: "test classification".to_string(),
442            classified_at: Utc::now(),
443        }
444    }
445
446    fn default_engine() -> AutoReplyEngine {
447        AutoReplyEngine::new(IntelligenceConfig::default())
448    }
449
450    // --- PendingReply Tests ---
451
452    #[test]
453    fn test_pending_reply_new() {
454        let reply = PendingReply::new("slack", "alice", "Hello?", MessagePriority::Normal);
455        assert_eq!(reply.channel_name, "slack");
456        assert_eq!(reply.recipient, "alice");
457        assert_eq!(reply.status, ReplyStatus::Drafting);
458        assert!(reply.draft_response.is_empty());
459    }
460
461    #[test]
462    fn test_pending_reply_with_draft() {
463        let reply = PendingReply::new("slack", "alice", "Hello?", MessagePriority::Normal)
464            .with_draft("Hi there! How can I help?");
465        assert_eq!(reply.status, ReplyStatus::PendingApproval);
466        assert_eq!(reply.draft_response, "Hi there! How can I help?");
467    }
468
469    #[test]
470    fn test_pending_reply_lifecycle() {
471        let mut reply = PendingReply::new("slack", "alice", "Hello?", MessagePriority::Normal)
472            .with_draft("Reply text");
473
474        assert_eq!(reply.status, ReplyStatus::PendingApproval);
475        assert!(reply.is_actionable());
476
477        reply.try_approve().unwrap();
478        assert_eq!(reply.status, ReplyStatus::Approved);
479        assert!(reply.is_actionable());
480
481        reply.try_mark_sent().unwrap();
482        assert_eq!(reply.status, ReplyStatus::Sent);
483        assert!(!reply.is_actionable());
484    }
485
486    #[test]
487    fn test_pending_reply_reject() {
488        let mut reply = PendingReply::new("slack", "alice", "Hello?", MessagePriority::Normal)
489            .with_draft("Reply text");
490        reply.try_reject().unwrap();
491        assert_eq!(reply.status, ReplyStatus::Rejected);
492        assert!(!reply.is_actionable());
493    }
494
495    #[test]
496    fn test_pending_reply_expire() {
497        let mut reply = PendingReply::new("slack", "alice", "Hello?", MessagePriority::Normal)
498            .with_draft("Reply text");
499        reply.try_expire().unwrap();
500        assert_eq!(reply.status, ReplyStatus::Expired);
501        assert!(!reply.is_actionable());
502    }
503
504    #[test]
505    fn test_try_approve_from_rejected_fails() {
506        let mut reply =
507            PendingReply::new("slack", "alice", "Q", MessagePriority::Normal).with_draft("Reply");
508        reply.try_reject().unwrap();
509        assert!(reply.try_approve().is_err());
510    }
511
512    #[test]
513    fn test_try_mark_sent_from_pending_fails() {
514        let mut reply =
515            PendingReply::new("slack", "alice", "Q", MessagePriority::Normal).with_draft("Reply");
516        assert!(reply.try_mark_sent().is_err());
517    }
518
519    #[test]
520    fn test_try_expire_from_sent_fails() {
521        let mut reply =
522            PendingReply::new("slack", "alice", "Q", MessagePriority::Normal).with_draft("Reply");
523        reply.try_approve().unwrap();
524        reply.try_mark_sent().unwrap();
525        assert!(reply.try_expire().is_err());
526    }
527
528    #[test]
529    fn test_valid_full_lifecycle_path() {
530        let mut reply = PendingReply::new("slack", "alice", "Q", MessagePriority::Normal);
531        assert_eq!(reply.status, ReplyStatus::Drafting);
532        // Drafting -> PendingApproval (via with_draft)
533        reply = reply.with_draft("Draft text");
534        assert_eq!(reply.status, ReplyStatus::PendingApproval);
535        // PendingApproval -> Approved
536        reply.try_approve().unwrap();
537        assert_eq!(reply.status, ReplyStatus::Approved);
538        // Approved -> Sent
539        reply.try_mark_sent().unwrap();
540        assert_eq!(reply.status, ReplyStatus::Sent);
541    }
542
543    // --- AutoReplyEngine Tests ---
544
545    #[test]
546    fn test_engine_process_auto_reply_full_auto_normal() {
547        let mut engine = default_engine();
548        let classified = make_classified(
549            "What time is the meeting?",
550            MessagePriority::Normal,
551            MessageType::Question,
552            SuggestedAction::AutoReply,
553        );
554        let reply = engine.process_classified(&classified, "slack");
555        assert!(reply.is_some());
556        let reply = reply.unwrap();
557        // FullAuto + Normal -> auto-approved
558        assert_eq!(reply.status, ReplyStatus::Approved);
559        assert_eq!(reply.recipient, "Alice");
560    }
561
562    #[test]
563    fn test_engine_process_auto_reply_full_auto_urgent() {
564        let mut engine = default_engine();
565        let classified = make_classified(
566            "URGENT: production is down",
567            MessagePriority::Urgent,
568            MessageType::Question,
569            SuggestedAction::AutoReply,
570        );
571        let reply = engine.process_classified(&classified, "slack");
572        assert!(reply.is_some());
573        let reply = reply.unwrap();
574        // FullAuto + Urgent -> needs approval
575        assert_eq!(reply.status, ReplyStatus::PendingApproval);
576    }
577
578    #[test]
579    fn test_engine_process_draft_reply() {
580        let mut engine = default_engine();
581        let classified = make_classified(
582            "Can you explain how this works?",
583            MessagePriority::Normal,
584            MessageType::Question,
585            SuggestedAction::DraftReply,
586        );
587        let reply = engine.process_classified(&classified, "email");
588        assert!(reply.is_some());
589        let reply = reply.unwrap();
590        assert_eq!(reply.status, ReplyStatus::PendingApproval);
591    }
592
593    #[test]
594    fn test_engine_process_non_reply_action() {
595        let mut engine = default_engine();
596        let classified = make_classified(
597            "Interesting news",
598            MessagePriority::Low,
599            MessageType::Notification,
600            SuggestedAction::AddToDigest,
601        );
602        let reply = engine.process_classified(&classified, "slack");
603        assert!(reply.is_none());
604    }
605
606    #[test]
607    fn test_engine_process_ignore_action() {
608        let mut engine = default_engine();
609        let classified = make_classified(
610            "spam spam spam",
611            MessagePriority::Low,
612            MessageType::Spam,
613            SuggestedAction::Ignore,
614        );
615        let reply = engine.process_classified(&classified, "slack");
616        assert!(reply.is_none());
617    }
618
619    #[test]
620    fn test_engine_enqueue_and_query() {
621        let mut engine = default_engine();
622        let reply1 = PendingReply::new("slack", "alice", "Q1", MessagePriority::Normal)
623            .with_draft("Reply 1");
624        let reply2 =
625            PendingReply::new("slack", "bob", "Q2", MessagePriority::Normal).with_draft("Reply 2");
626
627        engine.enqueue(reply1);
628        engine.enqueue(reply2);
629
630        assert_eq!(engine.pending_count(), 2);
631        assert_eq!(engine.pending_approval().len(), 2);
632        assert_eq!(engine.ready_to_send().len(), 0);
633    }
634
635    #[test]
636    fn test_engine_approve_and_send() {
637        let mut engine = default_engine();
638        let reply = PendingReply::new("slack", "alice", "Q1", MessagePriority::Normal)
639            .with_draft("Reply 1");
640        let id = reply.id;
641        engine.enqueue(reply);
642
643        assert!(engine.approve_reply(id));
644        assert_eq!(engine.ready_to_send().len(), 1);
645
646        assert!(engine.mark_sent(id));
647        assert_eq!(engine.sent_count(), 1);
648        assert_eq!(engine.ready_to_send().len(), 0);
649    }
650
651    #[test]
652    fn test_engine_reject_reply() {
653        let mut engine = default_engine();
654        let reply = PendingReply::new("slack", "alice", "Q1", MessagePriority::Normal)
655            .with_draft("Reply 1");
656        let id = reply.id;
657        engine.enqueue(reply);
658
659        assert!(engine.reject_reply(id));
660        assert_eq!(engine.pending_approval().len(), 0);
661    }
662
663    #[test]
664    fn test_engine_approve_nonexistent() {
665        let mut engine = default_engine();
666        assert!(!engine.approve_reply(Uuid::new_v4()));
667    }
668
669    #[test]
670    fn test_engine_cleanup_completed() {
671        let mut engine = default_engine();
672
673        let mut reply1 = PendingReply::new("slack", "alice", "Q1", MessagePriority::Normal)
674            .with_draft("Reply 1");
675        reply1.try_approve().unwrap();
676        reply1.try_mark_sent().unwrap();
677        engine.enqueue(reply1);
678
679        let mut reply2 =
680            PendingReply::new("slack", "bob", "Q2", MessagePriority::Normal).with_draft("Reply 2");
681        reply2.try_reject().unwrap();
682        engine.enqueue(reply2);
683
684        let reply3 = PendingReply::new("slack", "carol", "Q3", MessagePriority::Normal)
685            .with_draft("Reply 3");
686        engine.enqueue(reply3);
687
688        assert_eq!(engine.pending_count(), 3);
689        let cleaned = engine.cleanup_completed();
690        assert_eq!(cleaned, 2);
691        assert_eq!(engine.pending_count(), 1);
692    }
693
694    #[test]
695    fn test_engine_rate_limiting() {
696        let config = IntelligenceConfig {
697            max_reply_tokens: 200, // Low token limit -> low rate limit
698            ..IntelligenceConfig::default()
699        };
700        let mut engine = AutoReplyEngine::new(config);
701        // max_replies_per_hour = max(200/100, 10) = 10
702        // Exhaust rate limit by adding 10 timestamps within the last hour
703        for _ in 0..10 {
704            engine.reply_timestamps.push_back(Utc::now());
705        }
706        let classified = make_classified(
707            "What time?",
708            MessagePriority::Normal,
709            MessageType::Question,
710            SuggestedAction::AutoReply,
711        );
712        let reply = engine.process_classified(&classified, "slack");
713        assert!(reply.is_none(), "Should be rate limited");
714    }
715
716    #[test]
717    fn test_engine_reset_rate_limit() {
718        let mut engine = default_engine();
719        for _ in 0..5 {
720            engine.reply_timestamps.push_back(Utc::now());
721        }
722        engine.reset_rate_limit();
723        assert_eq!(engine.sent_count(), 0);
724    }
725
726    #[test]
727    fn test_engine_rate_limit_window_expiry() {
728        let config = IntelligenceConfig {
729            max_reply_tokens: 200,
730            ..IntelligenceConfig::default()
731        };
732        let mut engine = AutoReplyEngine::new(config);
733        // Add 10 timestamps from 2 hours ago (outside the 1-hour window)
734        let old = Utc::now() - chrono::Duration::hours(2);
735        for _ in 0..10 {
736            engine.reply_timestamps.push_back(old);
737        }
738        // Should NOT be rate limited because all timestamps are outside the window
739        let classified = make_classified(
740            "What time?",
741            MessagePriority::Normal,
742            MessageType::Question,
743            SuggestedAction::AutoReply,
744        );
745        let reply = engine.process_classified(&classified, "slack");
746        assert!(
747            reply.is_some(),
748            "Old timestamps should have expired from the window"
749        );
750    }
751
752    #[test]
753    fn test_engine_expire_old_replies() {
754        let mut engine = default_engine();
755        let mut reply = PendingReply::new("slack", "alice", "Q1", MessagePriority::Normal)
756            .with_draft("Reply 1");
757        // Set creation time to 2 hours ago
758        reply.created_at = Utc::now() - chrono::Duration::hours(2);
759        engine.enqueue(reply);
760
761        let expired = engine.expire_old_replies(3600); // 1 hour
762        assert_eq!(expired, 1);
763        assert_eq!(engine.pending_approval().len(), 0);
764    }
765
766    #[test]
767    fn test_engine_expire_does_not_expire_recent() {
768        let mut engine = default_engine();
769        let reply = PendingReply::new("slack", "alice", "Q1", MessagePriority::Normal)
770            .with_draft("Reply 1");
771        engine.enqueue(reply);
772
773        let expired = engine.expire_old_replies(3600);
774        assert_eq!(expired, 0);
775        assert_eq!(engine.pending_approval().len(), 1);
776    }
777}