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