Skip to main content

batty_cli/team/delivery/
mod.rs

1mod routing;
2mod telegram;
3mod verification;
4
5use std::time::{Duration, Instant};
6
7use crate::tmux;
8
9pub(super) const DELIVERY_VERIFICATION_CAPTURE_LINES: u32 = 50;
10/// Increased capture window for agents that recently became ready, to account
11/// for startup output pushing the delivery marker further up the scrollback.
12#[allow(dead_code)]
13pub(super) const DELIVERY_VERIFICATION_CAPTURE_LINES_RECENTLY_READY: u32 = 100;
14pub(super) const FAILED_DELIVERY_RETRY_DELAY: Duration = Duration::from_secs(30);
15pub(super) const FAILED_DELIVERY_MAX_ATTEMPTS: u32 = 3;
16
17/// Check whether an agent's pane is showing a ready prompt by capturing
18/// the last 20 lines and looking for known agent input indicators.
19pub(super) fn is_agent_ready(pane_id: &str) -> bool {
20    match tmux::capture_pane_recent(pane_id, 20) {
21        Ok(capture) => super::watcher::is_at_agent_prompt(&capture),
22        Err(_) => false,
23    }
24}
25
26#[derive(Debug, Clone)]
27pub(super) struct PendingMessage {
28    pub(super) from: String,
29    pub(super) body: String,
30    #[allow(dead_code)] // Useful for future queue-age diagnostics.
31    pub(super) queued_at: Instant,
32}
33
34#[derive(Debug, Clone)]
35pub(super) struct FailedDelivery {
36    pub(super) recipient: String,
37    pub(super) from: String,
38    pub(super) body: String,
39    pub(super) attempts: u32,
40    pub(super) last_attempt: Instant,
41}
42
43impl FailedDelivery {
44    pub(super) fn new(recipient: &str, from: &str, body: &str) -> Self {
45        Self {
46            recipient: recipient.to_string(),
47            from: from.to_string(),
48            body: body.to_string(),
49            attempts: 1,
50            last_attempt: Instant::now(),
51        }
52    }
53
54    pub(super) fn message_marker(&self) -> String {
55        message_delivery_marker(&self.from)
56    }
57
58    fn is_ready_for_retry(&self, now: Instant) -> bool {
59        now.duration_since(self.last_attempt) >= FAILED_DELIVERY_RETRY_DELAY
60    }
61
62    fn has_attempts_remaining(&self) -> bool {
63        self.attempts < FAILED_DELIVERY_MAX_ATTEMPTS
64    }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub(super) enum MessageDelivery {
69    Channel,
70    LivePane,
71    InboxQueued,
72    DeferredPending,
73    SkippedUnknownRecipient,
74}
75
76pub(super) fn message_delivery_marker(sender: &str) -> String {
77    format!("--- Message from {sender} ---")
78}
79
80pub(super) fn capture_contains_message_marker(capture: &str, message_marker: &str) -> bool {
81    capture.contains(message_marker)
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn delivery_confirm_marker_detection_matches_captured_text() {
90        let marker = message_delivery_marker("manager");
91        let capture = format!("prompt\n{marker}\nbody\n");
92        assert!(capture_contains_message_marker(&capture, &marker));
93        assert!(!capture_contains_message_marker("prompt only", &marker));
94    }
95
96    #[test]
97    fn delivery_confirm_marker_generation_uses_sender_header() {
98        assert_eq!(
99            message_delivery_marker("eng-1-4"),
100            "--- Message from eng-1-4 ---"
101        );
102    }
103
104    #[test]
105    fn failed_delivery_new_sets_expected_fields() {
106        let delivery = FailedDelivery::new("eng-1", "manager", "Please retry this.");
107        assert_eq!(delivery.recipient, "eng-1");
108        assert_eq!(delivery.from, "manager");
109        assert_eq!(delivery.body, "Please retry this.");
110        assert_eq!(delivery.attempts, 1);
111        assert_eq!(delivery.message_marker(), "--- Message from manager ---");
112        assert!(delivery.has_attempts_remaining());
113    }
114
115    #[test]
116    fn message_delivery_variants_are_distinct() {
117        assert_ne!(MessageDelivery::Channel, MessageDelivery::LivePane);
118        assert_ne!(MessageDelivery::LivePane, MessageDelivery::InboxQueued);
119        assert_ne!(
120            MessageDelivery::InboxQueued,
121            MessageDelivery::SkippedUnknownRecipient
122        );
123        assert_eq!(MessageDelivery::Channel, MessageDelivery::Channel);
124    }
125
126    #[test]
127    fn delivery_verification_constants_are_sane() {
128        const {
129            assert!(
130                DELIVERY_VERIFICATION_CAPTURE_LINES_RECENTLY_READY
131                    > DELIVERY_VERIFICATION_CAPTURE_LINES
132            );
133            assert!(DELIVERY_VERIFICATION_CAPTURE_LINES > 0);
134            assert!(FAILED_DELIVERY_MAX_ATTEMPTS >= 2);
135        }
136        assert!(FAILED_DELIVERY_RETRY_DELAY >= Duration::from_secs(1));
137    }
138
139    #[test]
140    fn is_agent_ready_returns_false_for_nonexistent_pane() {
141        assert!(!is_agent_ready("%99999999"));
142    }
143
144    // --- FailedDelivery struct ---
145
146    #[test]
147    fn failed_delivery_is_not_ready_for_retry_when_recent() {
148        let delivery = FailedDelivery::new("eng-1", "manager", "test");
149        // Just created — last_attempt is now, so not ready for retry
150        assert!(!delivery.is_ready_for_retry(Instant::now()));
151    }
152
153    #[test]
154    fn failed_delivery_is_ready_for_retry_after_delay() {
155        let mut delivery = FailedDelivery::new("eng-1", "manager", "test");
156        delivery.last_attempt =
157            Instant::now() - FAILED_DELIVERY_RETRY_DELAY - Duration::from_secs(1);
158        assert!(delivery.is_ready_for_retry(Instant::now()));
159    }
160
161    #[test]
162    fn failed_delivery_has_attempts_remaining_at_boundary() {
163        let mut delivery = FailedDelivery::new("eng-1", "manager", "test");
164        delivery.attempts = FAILED_DELIVERY_MAX_ATTEMPTS - 1;
165        assert!(delivery.has_attempts_remaining());
166        delivery.attempts = FAILED_DELIVERY_MAX_ATTEMPTS;
167        assert!(!delivery.has_attempts_remaining());
168    }
169
170    #[test]
171    fn failed_delivery_message_marker_uses_from_field() {
172        let delivery = FailedDelivery::new("eng-1", "architect", "body");
173        assert_eq!(delivery.message_marker(), "--- Message from architect ---");
174    }
175
176    // --- Capture contains marker ---
177
178    #[test]
179    fn capture_contains_marker_empty_capture() {
180        assert!(!capture_contains_message_marker(
181            "",
182            "--- Message from x ---"
183        ));
184    }
185
186    #[test]
187    fn capture_contains_marker_partial_match_fails() {
188        let marker = message_delivery_marker("manager");
189        assert!(!capture_contains_message_marker(
190            "--- Message from",
191            &marker
192        ));
193    }
194
195    #[test]
196    fn capture_contains_marker_multiline_capture() {
197        let marker = message_delivery_marker("eng-1");
198        let capture = "line1\nline2\n--- Message from eng-1 ---\nline4\n";
199        assert!(capture_contains_message_marker(capture, &marker));
200    }
201
202    // --- Error path and recovery tests (Task #265) ---
203
204    #[test]
205    fn failed_delivery_not_ready_for_immediate_retry() {
206        let fd = FailedDelivery::new("eng-1", "manager", "test message");
207        // Just created — not enough time has passed for retry
208        assert!(!fd.is_ready_for_retry(Instant::now()));
209    }
210
211    #[test]
212    fn failed_delivery_ready_after_delay() {
213        let mut fd = FailedDelivery::new("eng-1", "manager", "test message");
214        // Simulate past creation
215        fd.last_attempt = Instant::now() - FAILED_DELIVERY_RETRY_DELAY - Duration::from_secs(1);
216        assert!(fd.is_ready_for_retry(Instant::now()));
217    }
218
219    #[test]
220    fn failed_delivery_has_attempts_remaining_exhausted() {
221        let mut fd = FailedDelivery::new("eng-1", "manager", "test message");
222        assert!(fd.has_attempts_remaining()); // attempts=1, max=3
223        fd.attempts = FAILED_DELIVERY_MAX_ATTEMPTS;
224        assert!(!fd.has_attempts_remaining());
225    }
226
227    #[test]
228    fn failed_delivery_message_marker_format() {
229        let fd = FailedDelivery::new("eng-1", "manager", "test message");
230        let marker = fd.message_marker();
231        assert!(marker.contains("manager"));
232    }
233
234    #[test]
235    fn failed_delivery_fields_preserved() {
236        let fd = FailedDelivery::new("eng-1", "manager", "hello world");
237        assert_eq!(fd.recipient, "eng-1");
238        assert_eq!(fd.from, "manager");
239        assert_eq!(fd.body, "hello world");
240        assert_eq!(fd.attempts, 1);
241    }
242}