Skip to main content

batty_cli/team/
message.rs

1//! Team message types and tmux injection.
2
3use std::fs::{File, OpenOptions};
4use std::io::{BufRead, BufReader, Write};
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9
10use crate::tmux;
11
12/// The end-of-message marker used by paste verification and stuck detection.
13const END_MARKER: &str = "--- end message ---";
14/// Maximum polls when waiting for paste to appear in the pane.
15const PASTE_VERIFY_MAX_POLLS: u32 = 10;
16/// Interval between paste verification polls (milliseconds).
17const PASTE_VERIFY_POLL_MS: u64 = 200;
18/// Number of pane lines to capture when verifying paste landed.
19const PASTE_VERIFY_CAPTURE_LINES: u32 = 30;
20/// Maximum Enter-key submission attempts when message appears stuck.
21const SUBMIT_MAX_ATTEMPTS: u32 = 3;
22/// Delay between Enter retries when checking for stuck messages (milliseconds).
23const SUBMIT_RETRY_DELAY_MS: u64 = 800;
24/// Number of pane lines captured to detect a stuck message.
25const STUCK_CAPTURE_LINES: u32 = 5;
26
27/// A message in the command queue.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(tag = "type")]
30pub enum QueuedCommand {
31    #[serde(rename = "send")]
32    Send {
33        from: String,
34        to: String,
35        message: String,
36    },
37    #[serde(rename = "assign")]
38    Assign {
39        from: String,
40        engineer: String,
41        task: String,
42    },
43}
44
45/// Inject a text message into a tmux pane.
46///
47/// Uses load-buffer + paste-buffer for reliable delivery of long/special
48/// content, then polls the pane to confirm the paste landed before
49/// sending Enter. If the message appears stuck (end marker still at
50/// the bottom of the pane), Enter is retried up to [`SUBMIT_MAX_ATTEMPTS`].
51pub fn inject_message(pane_id: &str, from: &str, message: &str) -> Result<()> {
52    let formatted = format!(
53        "\n--- Message from {from} ---\n{message}\n{END_MARKER}\nTo reply, run: batty send {from} \"<your response>\"\n"
54    );
55
56    // Send a pre-injection Enter to wake up idle agents whose prompt may be stuck
57    // (e.g., Claude Code with unsent input in the prompt buffer)
58    let _ = tmux::send_keys(pane_id, "", true);
59    std::thread::sleep(std::time::Duration::from_millis(200));
60
61    paste_message_with_retry(pane_id, &formatted)?;
62
63    // Wait for the paste to appear in the pane before sending Enter.
64    // This replaces a fixed delay with active verification, avoiding the race
65    // where Enter arrives before the paste buffer is processed.
66    if !wait_for_paste(pane_id) {
67        // Paste not confirmed — fall back to length-proportional delay.
68        let delay_ms = 500 + (formatted.len() as u64 / 100) * 50;
69        std::thread::sleep(std::time::Duration::from_millis(delay_ms.min(3000)));
70    }
71
72    // Submit the pasted text with Enter, retrying if the message is stuck.
73    submit_with_retry(pane_id)?;
74
75    Ok(())
76}
77
78/// Poll the pane capture until the end marker appears, confirming that the
79/// paste-buffer operation completed in the terminal.
80fn wait_for_paste(pane_id: &str) -> bool {
81    for _ in 0..PASTE_VERIFY_MAX_POLLS {
82        std::thread::sleep(std::time::Duration::from_millis(PASTE_VERIFY_POLL_MS));
83        if let Ok(capture) = tmux::capture_pane_recent(pane_id, PASTE_VERIFY_CAPTURE_LINES) {
84            if capture.contains(END_MARKER) {
85                return true;
86            }
87        }
88    }
89    false
90}
91
92/// Send Enter to submit the pasted message. After each attempt, check whether
93/// the message is stuck (end marker still visible in the bottom few lines of
94/// the pane). If stuck, resend Enter. Up to [`SUBMIT_MAX_ATTEMPTS`] tries.
95fn submit_with_retry(pane_id: &str) -> Result<()> {
96    for attempt in 0..SUBMIT_MAX_ATTEMPTS {
97        tmux::send_keys(pane_id, "", true)?;
98
99        if attempt < SUBMIT_MAX_ATTEMPTS - 1 {
100            std::thread::sleep(std::time::Duration::from_millis(SUBMIT_RETRY_DELAY_MS));
101            let capture =
102                tmux::capture_pane_recent(pane_id, STUCK_CAPTURE_LINES).unwrap_or_default();
103            if !is_message_stuck_in_capture(&capture) {
104                break;
105            }
106            // Message still visible at bottom — Enter was likely lost, retry.
107        }
108    }
109    Ok(())
110}
111
112/// Check whether a short pane capture contains the message end marker,
113/// indicating the message was pasted but not yet submitted (stuck).
114pub(crate) fn is_message_stuck_in_capture(capture: &str) -> bool {
115    capture.contains(END_MARKER)
116}
117
118fn paste_message_with_retry(pane_id: &str, formatted: &str) -> Result<()> {
119    tmux::load_buffer(formatted)?;
120    match tmux::paste_buffer(pane_id) {
121        Ok(()) => Ok(()),
122        Err(error) if error.to_string().contains("no buffer batty-inject") => {
123            tmux::load_buffer(formatted)?;
124            tmux::paste_buffer(pane_id)
125        }
126        Err(error) => Err(error),
127    }
128}
129
130/// Write a command to the queue file.
131pub fn enqueue_command(queue_path: &Path, cmd: &QueuedCommand) -> Result<()> {
132    if let Some(parent) = queue_path.parent() {
133        std::fs::create_dir_all(parent)?;
134    }
135    let json = serde_json::to_string(cmd)?;
136    let mut file = OpenOptions::new()
137        .create(true)
138        .append(true)
139        .open(queue_path)
140        .with_context(|| format!("failed to open command queue: {}", queue_path.display()))?;
141    writeln!(file, "{json}")?;
142    Ok(())
143}
144
145/// Read and drain all pending commands from the queue file.
146#[cfg(test)]
147pub fn drain_command_queue(queue_path: &Path) -> Result<Vec<QueuedCommand>> {
148    let commands = read_command_queue(queue_path)?;
149    if !commands.is_empty() {
150        write_command_queue(queue_path, &[])?;
151    }
152    Ok(commands)
153}
154
155/// Read all pending commands from the queue file without clearing it.
156pub fn read_command_queue(queue_path: &Path) -> Result<Vec<QueuedCommand>> {
157    if !queue_path.exists() {
158        return Ok(Vec::new());
159    }
160
161    let file = File::open(queue_path)
162        .with_context(|| format!("failed to open command queue: {}", queue_path.display()))?;
163    let reader = BufReader::new(file);
164
165    let mut commands = Vec::new();
166    for line in reader.lines() {
167        let line = line?;
168        let trimmed = line.trim();
169        if trimmed.is_empty() {
170            continue;
171        }
172        match serde_json::from_str::<QueuedCommand>(trimmed) {
173            Ok(cmd) => commands.push(cmd),
174            Err(e) => tracing::warn!(line = trimmed, error = %e, "skipping malformed command"),
175        }
176    }
177
178    Ok(commands)
179}
180
181/// Atomically rewrite the command queue with the remaining commands.
182pub fn write_command_queue(queue_path: &Path, commands: &[QueuedCommand]) -> Result<()> {
183    if let Some(parent) = queue_path.parent() {
184        std::fs::create_dir_all(parent)?;
185    }
186
187    let tmp_path = queue_path.with_extension("jsonl.tmp");
188    {
189        let mut file = OpenOptions::new()
190            .create(true)
191            .write(true)
192            .truncate(true)
193            .open(&tmp_path)
194            .with_context(|| {
195                format!("failed to open temp command queue: {}", tmp_path.display())
196            })?;
197        for cmd in commands {
198            let json = serde_json::to_string(cmd)?;
199            writeln!(file, "{json}")?;
200        }
201        file.flush()?;
202    }
203
204    std::fs::rename(&tmp_path, queue_path).with_context(|| {
205        format!(
206            "failed to replace command queue {} with {}",
207            queue_path.display(),
208            tmp_path.display()
209        )
210    })?;
211    Ok(())
212}
213
214/// Resolve the command queue path.
215pub fn command_queue_path(project_root: &Path) -> PathBuf {
216    project_root
217        .join(".batty")
218        .join("team_config")
219        .join("commands.jsonl")
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use serial_test::serial;
226
227    #[test]
228    fn send_command_roundtrip() {
229        let cmd = QueuedCommand::Send {
230            from: "human".into(),
231            to: "architect".into(),
232            message: "prioritize auth".into(),
233        };
234        let json = serde_json::to_string(&cmd).unwrap();
235        let parsed: QueuedCommand = serde_json::from_str(&json).unwrap();
236        match parsed {
237            QueuedCommand::Send { from, to, message } => {
238                assert_eq!(from, "human");
239                assert_eq!(to, "architect");
240                assert_eq!(message, "prioritize auth");
241            }
242            _ => panic!("wrong variant"),
243        }
244    }
245
246    #[test]
247    fn assign_command_roundtrip() {
248        let cmd = QueuedCommand::Assign {
249            from: "black-lead".into(),
250            engineer: "eng-1-1".into(),
251            task: "fix bug".into(),
252        };
253        let json = serde_json::to_string(&cmd).unwrap();
254        let parsed: QueuedCommand = serde_json::from_str(&json).unwrap();
255        match parsed {
256            QueuedCommand::Assign {
257                from,
258                engineer,
259                task,
260            } => {
261                assert_eq!(from, "black-lead");
262                assert_eq!(engineer, "eng-1-1");
263                assert_eq!(task, "fix bug");
264            }
265            _ => panic!("wrong variant"),
266        }
267    }
268
269    #[test]
270    fn enqueue_and_drain() {
271        let tmp = tempfile::tempdir().unwrap();
272        let queue = tmp.path().join("commands.jsonl");
273
274        enqueue_command(
275            &queue,
276            &QueuedCommand::Send {
277                from: "human".into(),
278                to: "arch".into(),
279                message: "hello".into(),
280            },
281        )
282        .unwrap();
283        enqueue_command(
284            &queue,
285            &QueuedCommand::Assign {
286                from: "black-lead".into(),
287                engineer: "eng-1".into(),
288                task: "work".into(),
289            },
290        )
291        .unwrap();
292
293        let commands = drain_command_queue(&queue).unwrap();
294        assert_eq!(commands.len(), 2);
295
296        // After drain, queue should be empty
297        let commands = drain_command_queue(&queue).unwrap();
298        assert!(commands.is_empty());
299    }
300
301    #[test]
302    fn drain_nonexistent_queue_returns_empty() {
303        let tmp = tempfile::tempdir().unwrap();
304        let queue = tmp.path().join("nonexistent.jsonl");
305        let commands = drain_command_queue(&queue).unwrap();
306        assert!(commands.is_empty());
307    }
308
309    #[test]
310    fn read_command_queue_keeps_file_contents_intact() {
311        let tmp = tempfile::tempdir().unwrap();
312        let queue = tmp.path().join("commands.jsonl");
313        enqueue_command(
314            &queue,
315            &QueuedCommand::Send {
316                from: "human".into(),
317                to: "arch".into(),
318                message: "hello".into(),
319            },
320        )
321        .unwrap();
322
323        let commands = read_command_queue(&queue).unwrap();
324        assert_eq!(commands.len(), 1);
325        let persisted = std::fs::read_to_string(&queue).unwrap();
326        assert!(persisted.contains("\"message\":\"hello\""));
327    }
328
329    #[test]
330    fn write_command_queue_rewrites_remaining_commands_atomically() {
331        let tmp = tempfile::tempdir().unwrap();
332        let queue = tmp.path().join("commands.jsonl");
333        enqueue_command(
334            &queue,
335            &QueuedCommand::Send {
336                from: "human".into(),
337                to: "arch".into(),
338                message: "hello".into(),
339            },
340        )
341        .unwrap();
342
343        write_command_queue(
344            &queue,
345            &[QueuedCommand::Assign {
346                from: "manager".into(),
347                engineer: "eng-1".into(),
348                task: "Task #1".into(),
349            }],
350        )
351        .unwrap();
352
353        let commands = read_command_queue(&queue).unwrap();
354        assert_eq!(commands.len(), 1);
355        match &commands[0] {
356            QueuedCommand::Assign { engineer, task, .. } => {
357                assert_eq!(engineer, "eng-1");
358                assert_eq!(task, "Task #1");
359            }
360            other => panic!("expected assign command after rewrite, got {other:?}"),
361        }
362    }
363
364    #[test]
365    fn drain_skips_malformed_lines() {
366        let tmp = tempfile::tempdir().unwrap();
367        let queue = tmp.path().join("commands.jsonl");
368        std::fs::write(
369            &queue,
370            "not json\n{\"type\":\"assign\",\"from\":\"manager\",\"engineer\":\"e1\",\"task\":\"t1\"}\n",
371        )
372        .unwrap();
373        let commands = drain_command_queue(&queue).unwrap();
374        assert_eq!(commands.len(), 1);
375    }
376
377    #[test]
378    #[serial]
379    #[cfg_attr(not(feature = "integration"), ignore)]
380    fn test_inject_message_empty_message_writes_message_wrapper_to_pane() {
381        let session = "batty-test-message-empty";
382        let mut delivered = false;
383
384        for _attempt in 0..3 {
385            let _ = crate::tmux::kill_session(session);
386
387            let tmp = tempfile::tempdir().unwrap();
388            let log_path = tmp.path().join("message-empty.log");
389
390            crate::tmux::create_session(session, "cat", &[], "/tmp").unwrap();
391            let pane_id = crate::tmux::pane_id(session).unwrap();
392            crate::tmux::setup_pipe_pane(&pane_id, &log_path).unwrap();
393            std::thread::sleep(std::time::Duration::from_millis(200));
394
395            inject_message(&pane_id, "manager", "").unwrap();
396            let content = (0..30)
397                .find_map(|_| {
398                    let content = std::fs::read_to_string(&log_path).unwrap_or_default();
399                    let ready = content.contains("--- Message from manager ---")
400                        && content.contains("--- end message ---")
401                        && content.contains("batty send manager");
402                    if ready {
403                        Some(content)
404                    } else {
405                        std::thread::sleep(std::time::Duration::from_millis(100));
406                        None
407                    }
408                })
409                .unwrap_or_else(|| std::fs::read_to_string(&log_path).unwrap_or_default());
410
411            if content.contains("--- Message from manager ---")
412                && content.contains("--- end message ---")
413                && content.contains("batty send manager")
414            {
415                delivered = true;
416                crate::tmux::kill_session(session).unwrap();
417                break;
418            }
419
420            crate::tmux::kill_session(session).unwrap();
421            std::thread::sleep(std::time::Duration::from_millis(100));
422        }
423
424        assert!(delivered, "expected wrapped empty message to reach pane");
425    }
426
427    #[test]
428    #[serial]
429    #[cfg_attr(not(feature = "integration"), ignore)]
430    fn test_inject_message_long_special_message_preserves_content() {
431        let session = format!("batty-test-message-special-{}", std::process::id());
432        let _ = crate::tmux::kill_session(&session);
433
434        let tmp = tempfile::tempdir().unwrap();
435        let log_path = tmp.path().join("message-special.log");
436        let repeated = "x".repeat(600);
437        let long_message = format!(
438            "symbols: !@#$%^&*()[]{{}}<>?/\\\\|~`'\" {}\nline-2",
439            repeated
440        );
441
442        crate::tmux::create_session(&session, "cat", &[], "/tmp").unwrap();
443        crate::tmux::setup_pipe_pane(&session, &log_path).unwrap();
444        std::thread::sleep(std::time::Duration::from_millis(200));
445
446        inject_message(&session, "architect", &long_message).unwrap();
447        let content = (0..30)
448            .find_map(|_| {
449                let content = std::fs::read_to_string(&log_path).unwrap_or_default();
450                let ready = content.contains("--- Message from architect ---")
451                    && content.contains("symbols: !@#$%^&*()[]{}<>?/\\\\|~`'\"")
452                    && content.contains("line-2");
453                if ready {
454                    Some(content)
455                } else {
456                    std::thread::sleep(std::time::Duration::from_millis(100));
457                    None
458                }
459            })
460            .unwrap_or_else(|| std::fs::read_to_string(&log_path).unwrap_or_default());
461        assert!(content.contains("--- Message from architect ---"));
462        assert!(content.contains("symbols: !@#$%^&*()[]{}<>?/\\\\|~`'\""));
463        assert!(content.contains(&"x".repeat(200)));
464        assert!(content.contains("line-2"));
465        assert!(content.contains("batty send architect"));
466
467        crate::tmux::kill_session(&session).unwrap();
468    }
469
470    #[test]
471    fn test_drain_command_queue_skips_unknown_and_incomplete_commands() {
472        let tmp = tempfile::tempdir().unwrap();
473        let queue = tmp.path().join("commands.jsonl");
474        std::fs::write(
475            &queue,
476            concat!(
477                "{\"type\":\"noop\",\"from\":\"manager\"}\n",
478                "{\"type\":\"send\",\"from\":\"manager\",\"message\":\"missing recipient\"}\n",
479                "{\"type\":\"assign\",\"from\":\"manager\",\"engineer\":\"eng-1\"}\n",
480                "{\"type\":\"send\",\"from\":\"manager\",\"to\":\"architect\",\"message\":\"valid\"}\n",
481            ),
482        )
483        .unwrap();
484
485        let commands = drain_command_queue(&queue).unwrap();
486
487        assert_eq!(commands.len(), 1);
488        match &commands[0] {
489            QueuedCommand::Send { from, to, message } => {
490                assert_eq!(from, "manager");
491                assert_eq!(to, "architect");
492                assert_eq!(message, "valid");
493            }
494            other => panic!("expected valid send command, got {other:?}"),
495        }
496    }
497
498    // --- Additional serialization edge cases ---
499
500    #[test]
501    fn send_command_preserves_multiline_message() {
502        let cmd = QueuedCommand::Send {
503            from: "manager".into(),
504            to: "eng-1".into(),
505            message: "line one\nline two\nline three".into(),
506        };
507        let json = serde_json::to_string(&cmd).unwrap();
508        let parsed: QueuedCommand = serde_json::from_str(&json).unwrap();
509        match parsed {
510            QueuedCommand::Send { message, .. } => {
511                assert_eq!(message, "line one\nline two\nline three");
512            }
513            _ => panic!("wrong variant"),
514        }
515    }
516
517    #[test]
518    fn send_command_preserves_empty_message() {
519        let cmd = QueuedCommand::Send {
520            from: "human".into(),
521            to: "architect".into(),
522            message: "".into(),
523        };
524        let json = serde_json::to_string(&cmd).unwrap();
525        let parsed: QueuedCommand = serde_json::from_str(&json).unwrap();
526        match parsed {
527            QueuedCommand::Send { message, .. } => assert!(message.is_empty()),
528            _ => panic!("wrong variant"),
529        }
530    }
531
532    #[test]
533    fn send_command_preserves_unicode() {
534        let cmd = QueuedCommand::Send {
535            from: "人間".into(),
536            to: "アーキ".into(),
537            message: "日本語テスト 🚀".into(),
538        };
539        let json = serde_json::to_string(&cmd).unwrap();
540        let parsed: QueuedCommand = serde_json::from_str(&json).unwrap();
541        match parsed {
542            QueuedCommand::Send { from, to, message } => {
543                assert_eq!(from, "人間");
544                assert_eq!(to, "アーキ");
545                assert_eq!(message, "日本語テスト 🚀");
546            }
547            _ => panic!("wrong variant"),
548        }
549    }
550
551    #[test]
552    fn assign_command_preserves_special_chars_in_task() {
553        let cmd = QueuedCommand::Assign {
554            from: "manager".into(),
555            engineer: "eng-1-1".into(),
556            task: "Task #42: Fix \"auth\" — it's broken!".into(),
557        };
558        let json = serde_json::to_string(&cmd).unwrap();
559        let parsed: QueuedCommand = serde_json::from_str(&json).unwrap();
560        match parsed {
561            QueuedCommand::Assign { task, .. } => {
562                assert_eq!(task, "Task #42: Fix \"auth\" — it's broken!");
563            }
564            _ => panic!("wrong variant"),
565        }
566    }
567
568    // --- enqueue_command edge cases ---
569
570    #[test]
571    fn enqueue_creates_parent_directories() {
572        let tmp = tempfile::tempdir().unwrap();
573        let queue = tmp
574            .path()
575            .join("deep")
576            .join("nested")
577            .join("commands.jsonl");
578
579        enqueue_command(
580            &queue,
581            &QueuedCommand::Send {
582                from: "human".into(),
583                to: "arch".into(),
584                message: "hello".into(),
585            },
586        )
587        .unwrap();
588
589        assert!(queue.exists());
590        let commands = read_command_queue(&queue).unwrap();
591        assert_eq!(commands.len(), 1);
592    }
593
594    #[test]
595    fn enqueue_appends_multiple_commands_preserving_order() {
596        let tmp = tempfile::tempdir().unwrap();
597        let queue = tmp.path().join("commands.jsonl");
598
599        for i in 0..5 {
600            enqueue_command(
601                &queue,
602                &QueuedCommand::Send {
603                    from: "human".into(),
604                    to: "arch".into(),
605                    message: format!("msg-{i}"),
606                },
607            )
608            .unwrap();
609        }
610
611        let commands = read_command_queue(&queue).unwrap();
612        assert_eq!(commands.len(), 5);
613        for (i, cmd) in commands.iter().enumerate() {
614            match cmd {
615                QueuedCommand::Send { message, .. } => {
616                    assert_eq!(message, &format!("msg-{i}"));
617                }
618                _ => panic!("wrong variant at index {i}"),
619            }
620        }
621    }
622
623    // --- read_command_queue edge cases ---
624
625    #[test]
626    fn read_command_queue_skips_empty_lines() {
627        let tmp = tempfile::tempdir().unwrap();
628        let queue = tmp.path().join("commands.jsonl");
629        std::fs::write(
630            &queue,
631            "\n\n{\"type\":\"send\",\"from\":\"a\",\"to\":\"b\",\"message\":\"hi\"}\n\n\n",
632        )
633        .unwrap();
634
635        let commands = read_command_queue(&queue).unwrap();
636        assert_eq!(commands.len(), 1);
637    }
638
639    #[test]
640    fn read_command_queue_skips_whitespace_only_lines() {
641        let tmp = tempfile::tempdir().unwrap();
642        let queue = tmp.path().join("commands.jsonl");
643        std::fs::write(
644            &queue,
645            "   \n\t\n{\"type\":\"send\",\"from\":\"a\",\"to\":\"b\",\"message\":\"ok\"}\n  \n",
646        )
647        .unwrap();
648
649        let commands = read_command_queue(&queue).unwrap();
650        assert_eq!(commands.len(), 1);
651    }
652
653    #[test]
654    fn read_command_queue_mixed_valid_and_malformed() {
655        let tmp = tempfile::tempdir().unwrap();
656        let queue = tmp.path().join("commands.jsonl");
657        std::fs::write(
658            &queue,
659            concat!(
660                "{\"type\":\"send\",\"from\":\"a\",\"to\":\"b\",\"message\":\"first\"}\n",
661                "garbage line\n",
662                "{\"type\":\"assign\",\"from\":\"m\",\"engineer\":\"e1\",\"task\":\"t1\"}\n",
663                "{\"invalid json\n",
664                "{\"type\":\"send\",\"from\":\"c\",\"to\":\"d\",\"message\":\"last\"}\n",
665            ),
666        )
667        .unwrap();
668
669        let commands = read_command_queue(&queue).unwrap();
670        assert_eq!(
671            commands.len(),
672            3,
673            "should parse 3 valid commands, skip 2 malformed"
674        );
675    }
676
677    // --- write_command_queue edge cases ---
678
679    #[test]
680    fn write_command_queue_empty_slice_creates_empty_file() {
681        let tmp = tempfile::tempdir().unwrap();
682        let queue = tmp.path().join("commands.jsonl");
683
684        // First enqueue something
685        enqueue_command(
686            &queue,
687            &QueuedCommand::Send {
688                from: "a".into(),
689                to: "b".into(),
690                message: "hello".into(),
691            },
692        )
693        .unwrap();
694
695        // Overwrite with empty
696        write_command_queue(&queue, &[]).unwrap();
697
698        let commands = read_command_queue(&queue).unwrap();
699        assert!(commands.is_empty());
700    }
701
702    #[test]
703    fn write_command_queue_creates_parent_dirs() {
704        let tmp = tempfile::tempdir().unwrap();
705        let queue = tmp.path().join("sub").join("dir").join("q.jsonl");
706
707        write_command_queue(
708            &queue,
709            &[QueuedCommand::Assign {
710                from: "m".into(),
711                engineer: "e1".into(),
712                task: "t1".into(),
713            }],
714        )
715        .unwrap();
716
717        let commands = read_command_queue(&queue).unwrap();
718        assert_eq!(commands.len(), 1);
719    }
720
721    // --- command_queue_path ---
722
723    #[test]
724    fn command_queue_path_returns_expected_path() {
725        let root = Path::new("/project");
726        let path = command_queue_path(root);
727        assert_eq!(
728            path,
729            PathBuf::from("/project/.batty/team_config/commands.jsonl")
730        );
731    }
732
733    #[test]
734    fn command_queue_path_with_trailing_slash() {
735        let root = Path::new("/project/");
736        let path = command_queue_path(root);
737        assert_eq!(
738            path,
739            PathBuf::from("/project/.batty/team_config/commands.jsonl")
740        );
741    }
742
743    // --- drain_command_queue ---
744
745    #[test]
746    fn drain_empties_queue_file_but_keeps_file() {
747        let tmp = tempfile::tempdir().unwrap();
748        let queue = tmp.path().join("commands.jsonl");
749
750        enqueue_command(
751            &queue,
752            &QueuedCommand::Send {
753                from: "a".into(),
754                to: "b".into(),
755                message: "msg".into(),
756            },
757        )
758        .unwrap();
759
760        let drained = drain_command_queue(&queue).unwrap();
761        assert_eq!(drained.len(), 1);
762
763        // File should still exist but be effectively empty
764        assert!(queue.exists());
765        let commands = read_command_queue(&queue).unwrap();
766        assert!(commands.is_empty());
767    }
768
769    #[test]
770    fn drain_twice_second_returns_empty() {
771        let tmp = tempfile::tempdir().unwrap();
772        let queue = tmp.path().join("commands.jsonl");
773
774        enqueue_command(
775            &queue,
776            &QueuedCommand::Assign {
777                from: "m".into(),
778                engineer: "e".into(),
779                task: "t".into(),
780            },
781        )
782        .unwrap();
783
784        let first = drain_command_queue(&queue).unwrap();
785        assert_eq!(first.len(), 1);
786
787        let second = drain_command_queue(&queue).unwrap();
788        assert!(second.is_empty());
789    }
790
791    // --- QueuedCommand Debug derive ---
792
793    #[test]
794    fn queued_command_debug_format() {
795        let cmd = QueuedCommand::Send {
796            from: "human".into(),
797            to: "arch".into(),
798            message: "test".into(),
799        };
800        let debug = format!("{cmd:?}");
801        assert!(debug.contains("Send"));
802        assert!(debug.contains("human"));
803    }
804
805    #[test]
806    fn queued_command_clone() {
807        let cmd = QueuedCommand::Assign {
808            from: "manager".into(),
809            engineer: "eng-1".into(),
810            task: "build feature".into(),
811        };
812        let cloned = cmd.clone();
813        let json_original = serde_json::to_string(&cmd).unwrap();
814        let json_cloned = serde_json::to_string(&cloned).unwrap();
815        assert_eq!(json_original, json_cloned);
816    }
817
818    // --- JSON tag format verification ---
819
820    #[test]
821    fn send_command_json_has_type_send_tag() {
822        let cmd = QueuedCommand::Send {
823            from: "a".into(),
824            to: "b".into(),
825            message: "c".into(),
826        };
827        let json = serde_json::to_string(&cmd).unwrap();
828        assert!(json.contains("\"type\":\"send\""), "got: {json}");
829    }
830
831    #[test]
832    fn assign_command_json_has_type_assign_tag() {
833        let cmd = QueuedCommand::Assign {
834            from: "a".into(),
835            engineer: "b".into(),
836            task: "c".into(),
837        };
838        let json = serde_json::to_string(&cmd).unwrap();
839        assert!(json.contains("\"type\":\"assign\""), "got: {json}");
840    }
841
842    // ── inject_message Enter-key reliability tests ──
843
844    #[test]
845    fn injection_sequence_sends_enter_after_paste() {
846        // The formatted message must contain END_MARKER so wait_for_paste()
847        // can confirm the paste landed before Enter is sent.
848        let formatted = format!(
849            "\n--- Message from manager ---\nhello\n{}\nTo reply, run: batty send manager \"<your response>\"\n",
850            super::END_MARKER
851        );
852        assert!(
853            formatted.contains(super::END_MARKER),
854            "formatted message must contain the end marker used by paste verification"
855        );
856        // END_MARKER must appear before the final line so the capture window
857        // can reliably find it in the bottom N lines of the pane.
858        let marker_pos = formatted.rfind(super::END_MARKER).unwrap();
859        assert!(
860            marker_pos < formatted.len() - 1,
861            "end marker must not be the very last byte"
862        );
863    }
864
865    #[test]
866    fn verification_detects_stuck_message() {
867        // When the end marker is in the bottom capture, the message is stuck.
868        let stuck_capture =
869            "some output\n--- end message ---\nTo reply, run: batty send mgr \"<resp>\"";
870        assert!(
871            super::is_message_stuck_in_capture(stuck_capture),
872            "should detect stuck message when end marker is present"
873        );
874
875        // When the capture shows agent activity (no end marker), not stuck.
876        let active_capture = "Working on task...\n$ cargo test\nrunning 5 tests";
877        assert!(
878            !super::is_message_stuck_in_capture(active_capture),
879            "should not flag agent activity as stuck"
880        );
881    }
882
883    #[test]
884    fn retry_enter_on_stuck_detection() {
885        // Empty capture — message was consumed, not stuck.
886        assert!(
887            !super::is_message_stuck_in_capture(""),
888            "empty capture must not be stuck"
889        );
890
891        // Agent prompt only — not stuck.
892        assert!(
893            !super::is_message_stuck_in_capture("> "),
894            "agent prompt alone must not be stuck"
895        );
896
897        // Partial marker doesn't trigger stuck detection.
898        assert!(
899            !super::is_message_stuck_in_capture("--- end mess"),
900            "partial marker must not trigger stuck detection"
901        );
902
903        // Full end marker present — stuck.
904        assert!(
905            super::is_message_stuck_in_capture("text\n--- end message ---\nreply hint"),
906            "full end marker must trigger stuck detection"
907        );
908
909        // Multiple lines of agent output after the marker — still detected
910        // because the helper only checks presence, and caller limits capture
911        // to STUCK_CAPTURE_LINES (bottom 5 lines) to avoid false positives.
912        let capture_with_marker = "line1\nline2\n--- end message ---\nTo reply\nmore output";
913        assert!(
914            super::is_message_stuck_in_capture(capture_with_marker),
915            "end marker anywhere in the short capture means stuck"
916        );
917    }
918}