1use 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
12const END_MARKER: &str = "--- end message ---";
14const PASTE_VERIFY_MAX_POLLS: u32 = 10;
16const PASTE_VERIFY_POLL_MS: u64 = 200;
18const PASTE_VERIFY_CAPTURE_LINES: u32 = 30;
20const SUBMIT_MAX_ATTEMPTS: u32 = 3;
22const SUBMIT_RETRY_DELAY_MS: u64 = 800;
24const STUCK_CAPTURE_LINES: u32 = 5;
26
27#[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
45pub 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 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 if !wait_for_paste(pane_id) {
67 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_with_retry(pane_id)?;
74
75 Ok(())
76}
77
78fn 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
92fn 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 }
108 }
109 Ok(())
110}
111
112pub(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
130pub 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#[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
155pub 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
181pub 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
214pub 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 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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 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 #[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 #[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 #[test]
845 fn injection_sequence_sends_enter_after_paste() {
846 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 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 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 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 assert!(
887 !super::is_message_stuck_in_capture(""),
888 "empty capture must not be stuck"
889 );
890
891 assert!(
893 !super::is_message_stuck_in_capture("> "),
894 "agent prompt alone must not be stuck"
895 );
896
897 assert!(
899 !super::is_message_stuck_in_capture("--- end mess"),
900 "partial marker must not trigger stuck detection"
901 );
902
903 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 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}