1use std::collections::HashMap;
4use std::path::Path;
5
6use anyhow::{Context, Result, bail};
7use tracing::{info, warn};
8
9use super::{completion, config, discord, hierarchy, inbox, merge, team_config_path, telegram};
10
11const INBOX_BODY_PREVIEW_CHARS: usize = 140;
12
13fn resolve_role_name(project_root: &Path, member_name: &str) -> String {
16 if matches!(member_name, "human" | "daemon") {
18 return member_name.to_string();
19 }
20 let config_path = team_config_path(project_root);
21 if let Ok(team_config) = config::TeamConfig::load(&config_path) {
22 if let Ok(members) = hierarchy::resolve_hierarchy(&team_config) {
23 if let Some(m) = members.iter().find(|m| m.name == member_name) {
24 return m.role_name.clone();
25 }
26 }
27 }
28 member_name.to_string()
30}
31
32pub(crate) fn resolve_member_name(project_root: &Path, member_name: &str) -> Result<String> {
39 if matches!(member_name, "human" | "daemon") {
40 return Ok(member_name.to_string());
41 }
42
43 let config_path = team_config_path(project_root);
44 if let Ok(team_config) = config::TeamConfig::load(&config_path) {
45 if let Ok(members) = hierarchy::resolve_hierarchy(&team_config) {
46 if let Some(member) = members.iter().find(|m| m.name == member_name) {
47 return Ok(member.name.clone());
48 }
49
50 let matches: Vec<String> = members
51 .iter()
52 .filter(|m| m.role_name == member_name)
53 .map(|m| m.name.clone())
54 .collect();
55
56 return match matches.len() {
57 0 => Ok(member_name.to_string()),
58 1 => Ok(matches[0].clone()),
59 _ => bail!(
60 "'{member_name}' matches multiple members: {}. Use the explicit member name.",
61 matches.join(", ")
62 ),
63 };
64 }
65 }
66
67 Ok(member_name.to_string())
68}
69
70pub fn send_message(project_root: &Path, role: &str, msg: &str) -> Result<()> {
76 send_message_as(project_root, None, role, msg)
77}
78
79pub fn send_message_as(
80 project_root: &Path,
81 from_override: Option<&str>,
82 role: &str,
83 msg: &str,
84) -> Result<()> {
85 let from = effective_sender(project_root, from_override);
86 let recipient = resolve_member_name(project_root, role)?;
87
88 let config_path = team_config_path(project_root);
90 if config_path.exists() {
91 if let Ok(team_config) = config::TeamConfig::load(&config_path) {
92 let from_role = resolve_role_name(project_root, &from);
93 let to_role = resolve_role_name(project_root, &recipient);
94 if !team_config.can_talk(&from_role, &to_role) {
95 bail!(
96 "{from} ({from_role}) is not allowed to message {recipient} ({to_role}). \
97 Check talks_to in team.yaml."
98 );
99 }
100 }
101 }
102
103 let root = inbox::inboxes_root(project_root);
104 let inbox_msg = inbox::InboxMessage::new_send(&from, &recipient, msg);
105 let id = inbox::deliver_to_inbox(&root, &inbox_msg)?;
106 if let Err(error) = completion::ingest_completion_message(project_root, msg) {
107 warn!(from, to = %recipient, error = %error, "failed to ingest completion packet");
108 }
109 info!(to = %recipient, id = %id, "message delivered to inbox");
110 Ok(())
111}
112
113pub(crate) fn detect_sender() -> Option<String> {
116 if let Ok(member) = std::env::var("BATTY_MEMBER") {
118 if !member.is_empty() {
119 return Some(member);
120 }
121 }
122
123 let pane_id = std::env::var("TMUX_PANE").ok()?;
125 let output = std::process::Command::new("tmux")
126 .args(["show-options", "-p", "-t", &pane_id, "-v", "@batty_role"])
127 .output()
128 .ok()?;
129 if output.status.success() {
130 let role = String::from_utf8_lossy(&output.stdout).trim().to_string();
131 if !role.is_empty() { Some(role) } else { None }
132 } else {
133 None
134 }
135}
136
137fn effective_sender(project_root: &Path, from_override: Option<&str>) -> String {
138 let candidate = from_override
139 .map(str::to_string)
140 .or_else(detect_sender)
141 .unwrap_or_else(|| "human".to_string());
142
143 if sender_belongs_to_project(project_root, &candidate) {
144 candidate
145 } else {
146 "human".to_string()
147 }
148}
149
150fn sender_belongs_to_project(project_root: &Path, sender: &str) -> bool {
151 if matches!(sender, "human" | "daemon") {
152 return true;
153 }
154
155 let config_path = team_config_path(project_root);
156 let Ok(team_config) = config::TeamConfig::load(&config_path) else {
157 return false;
158 };
159
160 if team_config.roles.iter().any(|role| role.name == sender) {
161 return true;
162 }
163
164 hierarchy::resolve_hierarchy(&team_config)
165 .map(|members| members.iter().any(|member| member.name == sender))
166 .unwrap_or(false)
167}
168
169pub fn assign_task(project_root: &Path, engineer: &str, task: &str) -> Result<String> {
171 let from = effective_sender(project_root, None);
172 let recipient = resolve_member_name(project_root, engineer)?;
173
174 let config_path = team_config_path(project_root);
175 if config_path.exists() {
176 if let Ok(team_config) = config::TeamConfig::load(&config_path) {
177 let from_role = resolve_role_name(project_root, &from);
178 let to_role = resolve_role_name(project_root, &recipient);
179 if !team_config.can_talk(&from_role, &to_role) {
180 bail!(
181 "{from} ({from_role}) is not allowed to assign {recipient} ({to_role}). \
182 Check talks_to in team.yaml."
183 );
184 }
185 }
186 }
187
188 let root = inbox::inboxes_root(project_root);
189 let msg = inbox::InboxMessage::new_assign(&from, &recipient, task);
190 let id = inbox::deliver_to_inbox(&root, &msg)?;
191 info!(from, engineer = %recipient, task, id = %id, "assignment delivered to inbox");
192 Ok(id)
193}
194
195pub fn list_inbox(
202 project_root: &Path,
203 member: &str,
204 limit: Option<usize>,
205 raw: bool,
206) -> Result<()> {
207 let member = resolve_member_name(project_root, member)?;
208 let root = inbox::inboxes_root(project_root);
209 let messages = inbox::all_messages(&root, &member)?;
210 if raw {
211 print!("{}", format_inbox_listing(&member, &messages, limit));
212 } else {
213 let board_dir = board_dir_for(project_root);
214 print!(
215 "{}",
216 format_inbox_digest(&member, &messages, limit, Some(&board_dir))
217 );
218 }
219 Ok(())
220}
221
222fn board_dir_for(project_root: &Path) -> std::path::PathBuf {
223 project_root
224 .join(".batty")
225 .join("team_config")
226 .join("board")
227}
228
229fn format_inbox_digest(
230 member: &str,
231 messages: &[(inbox::InboxMessage, bool)],
232 limit: Option<usize>,
233 board_dir: Option<&std::path::Path>,
234) -> String {
235 if messages.is_empty() {
236 return format!("No messages for {member}.\n");
237 }
238
239 let (mut entries, raw_count) = inbox::digest_messages(messages);
240 if let Some(board_dir) = board_dir {
245 entries = inbox::demote_stale_escalations(entries, board_dir);
246 entries.sort_by(|a, b| {
249 a.category
250 .cmp(&b.category)
251 .then_with(|| b.message.timestamp.cmp(&a.message.timestamp))
252 });
253 }
254 let digest_count = entries.len();
255
256 let shown = match limit {
257 Some(0) => &entries[..0],
258 Some(n) => &entries[..n.min(entries.len())],
259 None => &entries[..],
260 };
261
262 let mut out = String::new();
263
264 let collapsed = raw_count.saturating_sub(digest_count);
266 if collapsed > 0 {
267 out.push_str(&format!(
268 "Digest: {digest_count} entries from {raw_count} messages ({collapsed} collapsed). Use `--raw` for raw view.\n",
269 ));
270 }
271
272 if shown.len() < entries.len() {
273 out.push_str(&format!(
274 "Showing {} of {} entries. Use `-n <N>` or `--all` to see more.\n",
275 shown.len(),
276 entries.len()
277 ));
278 }
279
280 out.push_str(&format!(
281 "{:<12} {:<10} {:<12} {:<6} BODY\n",
282 "CATEGORY", "STATUS", "FROM", "COUNT"
283 ));
284 out.push_str(&format!("{}\n", "-".repeat(96)));
285
286 for entry in shown {
287 let cat_label = match entry.category {
288 inbox::MessageCategory::Escalation => "ESCALATION",
289 inbox::MessageCategory::ReviewRequest => "REVIEW",
290 inbox::MessageCategory::Blocker => "BLOCKER",
291 inbox::MessageCategory::Status => "status",
292 inbox::MessageCategory::Nudge => "nudge",
293 };
294 let status = if entry.delivered {
295 "delivered"
296 } else {
297 "pending"
298 };
299 let count_str = if entry.collapsed_count > 1 {
300 format!("x{}", entry.collapsed_count)
301 } else {
302 String::new()
303 };
304 let body_short = truncate_chars(&entry.message.body, INBOX_BODY_PREVIEW_CHARS);
305 out.push_str(&format!(
306 "{:<12} {:<10} {:<12} {:<6} {}\n",
307 cat_label, status, entry.message.from, count_str, body_short,
308 ));
309 }
310 out
311}
312
313fn format_inbox_listing(
314 member: &str,
315 messages: &[(inbox::InboxMessage, bool)],
316 limit: Option<usize>,
317) -> String {
318 if messages.is_empty() {
319 return format!("No messages for {member}.\n");
320 }
321
322 let start = match limit {
323 Some(0) => messages.len(),
324 Some(n) => messages.len().saturating_sub(n),
325 None => 0,
326 };
327 let shown = &messages[start..];
328 let refs = inbox_message_refs(messages);
329 let shown_refs = &refs[start..];
330
331 let mut out = String::new();
332 if shown.len() < messages.len() {
333 out.push_str(&format!(
334 "Showing {} of {} messages for {member}. Use `-n <N>` or `--all` to see more.\n",
335 shown.len(),
336 messages.len()
337 ));
338 }
339 out.push_str(&format!(
340 "{:<10} {:<12} {:<12} {:<14} BODY\n",
341 "STATUS", "FROM", "TYPE", "REF"
342 ));
343 out.push_str(&format!("{}\n", "-".repeat(96)));
344 for ((msg, delivered), msg_ref) in shown.iter().zip(shown_refs.iter()) {
345 let status = if *delivered { "delivered" } else { "pending" };
346 let body_short = truncate_chars(&msg.body, INBOX_BODY_PREVIEW_CHARS);
347 out.push_str(&format!(
348 "{:<10} {:<12} {:<12} {:<14} {}\n",
349 status,
350 msg.from,
351 format!("{:?}", msg.msg_type).to_lowercase(),
352 msg_ref,
353 body_short,
354 ));
355 }
356 out
357}
358
359fn inbox_message_refs(messages: &[(inbox::InboxMessage, bool)]) -> Vec<String> {
360 let mut totals = HashMap::new();
361 for (msg, _) in messages {
362 *totals.entry(msg.timestamp).or_insert(0usize) += 1;
363 }
364
365 let mut seen = HashMap::new();
366 messages
367 .iter()
368 .map(|(msg, _)| {
369 let ordinal = seen.entry(msg.timestamp).or_insert(0usize);
370 *ordinal += 1;
371 if totals.get(&msg.timestamp).copied().unwrap_or(0) <= 1 {
372 msg.timestamp.to_string()
373 } else {
374 format!("{}-{}", msg.timestamp, ordinal)
375 }
376 })
377 .collect()
378}
379
380fn resolve_inbox_message_indices(
381 messages: &[(inbox::InboxMessage, bool)],
382 selector: &str,
383) -> Vec<usize> {
384 let refs = inbox_message_refs(messages);
385 messages
386 .iter()
387 .enumerate()
388 .filter_map(|(idx, (msg, _))| {
389 if msg.id == selector || msg.id.starts_with(selector) || refs[idx] == selector {
390 Some(idx)
391 } else {
392 None
393 }
394 })
395 .collect()
396}
397
398fn truncate_chars(input: &str, max_chars: usize) -> String {
399 if input.chars().count() <= max_chars {
400 return input.to_string();
401 }
402 let mut truncated: String = input.chars().take(max_chars).collect();
403 truncated.push_str("...");
404 truncated
405}
406
407pub fn read_message(project_root: &Path, member: &str, id: &str) -> Result<()> {
409 let member = resolve_member_name(project_root, member)?;
410 let root = inbox::inboxes_root(project_root);
411 let messages = inbox::all_messages(&root, &member)?;
412
413 let matching = resolve_inbox_message_indices(&messages, id);
414
415 match matching.len() {
416 0 => bail!("no message matching '{id}' in {member}'s inbox"),
417 1 => {
418 let (msg, delivered) = &messages[matching[0]];
419 let status = if *delivered { "delivered" } else { "pending" };
420 println!("ID: {}", msg.id);
421 println!("From: {}", msg.from);
422 println!("To: {}", msg.to);
423 println!("Type: {:?}", msg.msg_type);
424 println!("Status: {status}");
425 println!("Time: {}", msg.timestamp);
426 println!();
427 println!("{}", msg.body);
428 }
429 n => {
430 bail!(
431 "'{id}' matches {n} messages — use a longer prefix or the REF column from `batty inbox`"
432 );
433 }
434 }
435
436 Ok(())
437}
438
439pub fn ack_message(project_root: &Path, member: &str, id: &str) -> Result<()> {
441 let member = resolve_member_name(project_root, member)?;
442 let root = inbox::inboxes_root(project_root);
443 let messages = inbox::all_messages(&root, &member)?;
444 let matching = resolve_inbox_message_indices(&messages, id);
445 let resolved_id = match matching.len() {
446 0 => bail!("no message matching '{id}' in {member}'s inbox"),
447 1 => messages[matching[0]].0.id.clone(),
448 n => bail!(
449 "'{id}' matches {n} messages — use a longer prefix or the REF column from `batty inbox`"
450 ),
451 };
452 inbox::mark_delivered(&root, &member, &resolved_id)?;
453 info!(member, id = %resolved_id, "message acknowledged");
454 Ok(())
455}
456
457pub fn purge_inbox(
459 project_root: &Path,
460 member: Option<&str>,
461 all_roles: bool,
462 before: Option<u64>,
463 purge_all: bool,
464) -> Result<inbox::InboxPurgeSummary> {
465 if !purge_all && before.is_none() {
466 bail!("use `--all` or `--before <unix-timestamp>` with `batty inbox purge`");
467 }
468
469 let root = inbox::inboxes_root(project_root);
470 if all_roles {
471 return inbox::purge_delivered_messages_for_all(&root, before, purge_all);
472 }
473
474 let member = member.context("member is required unless using `--all-roles`")?;
475 let member = resolve_member_name(project_root, member)?;
476 let messages = inbox::purge_delivered_messages(&root, &member, before, purge_all)?;
477 Ok(inbox::InboxPurgeSummary { roles: 1, messages })
478}
479
480pub fn merge_worktree(project_root: &Path, engineer: &str) -> Result<()> {
482 let engineer = resolve_member_name(project_root, engineer)?;
483 match merge::merge_engineer_branch(project_root, &engineer)? {
484 merge::MergeOutcome::Success(_) => Ok(()),
485 merge::MergeOutcome::RebaseConflict(stderr) => {
486 bail!("merge blocked by rebase conflict: {stderr}")
487 }
488 merge::MergeOutcome::MergeFailure(stderr) => bail!("merge failed: {stderr}"),
489 }
490}
491
492pub fn setup_telegram(project_root: &Path) -> Result<()> {
494 telegram::setup_telegram(project_root)
495}
496
497pub fn setup_discord(project_root: &Path) -> Result<()> {
499 discord::setup_discord(project_root)
500}
501
502pub fn discord_status(project_root: &Path) -> Result<()> {
504 discord::discord_status(project_root)
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use crate::team::{board, inbox, team_config_dir, team_config_path};
511 use serial_test::serial;
512
513 struct EnvVarGuard {
514 key: &'static str,
515 original: Option<String>,
516 }
517
518 impl EnvVarGuard {
519 fn unset(key: &'static str) -> Self {
520 let original = std::env::var(key).ok();
521 unsafe {
522 std::env::remove_var(key);
523 }
524 Self { key, original }
525 }
526 }
527
528 impl Drop for EnvVarGuard {
529 fn drop(&mut self) {
530 match self.original.as_deref() {
531 Some(value) => unsafe {
532 std::env::set_var(self.key, value);
533 },
534 None => unsafe {
535 std::env::remove_var(self.key);
536 },
537 }
538 }
539 }
540
541 fn write_team_config(project_root: &Path, yaml: &str) {
542 std::fs::create_dir_all(team_config_dir(project_root)).unwrap();
543 std::fs::write(team_config_path(project_root), yaml).unwrap();
544 }
545
546 #[test]
547 fn send_message_delivers_to_inbox() {
548 let tmp = tempfile::tempdir().unwrap();
549 let _tmux_pane = EnvVarGuard::unset("TMUX_PANE");
550 let _batty_member = EnvVarGuard::unset("BATTY_MEMBER");
551 send_message(tmp.path(), "architect", "hello").unwrap();
552
553 let root = inbox::inboxes_root(tmp.path());
554 let pending = inbox::pending_messages(&root, "architect").unwrap();
555 assert_eq!(pending.len(), 1);
556 let expected_from = effective_sender(tmp.path(), None);
557 assert_eq!(pending[0].from, expected_from);
558 assert_eq!(pending[0].to, "architect");
559 assert_eq!(pending[0].body, "hello");
560 }
561
562 #[test]
563 fn send_message_ingests_completion_packet_into_workflow_metadata() {
564 let tmp = tempfile::tempdir().unwrap();
565 let tasks_dir = team_config_dir(tmp.path()).join("board").join("tasks");
566 std::fs::create_dir_all(&tasks_dir).unwrap();
567 let task_path = tasks_dir.join("027-completion-packets.md");
568 std::fs::write(
569 &task_path,
570 "---\nid: 27\ntitle: Completion packets\nstatus: review\npriority: medium\nclaimed_by: human\nclass: standard\n---\n\nTask body.\n",
571 )
572 .unwrap();
573
574 send_message(
575 tmp.path(),
576 "architect",
577 r#"Done.
578
579## Completion Packet
580
581```json
582{"task_id":27,"branch":"eng-1-4/task-27","worktree_path":".batty/worktrees/eng-1-4","commit":"abc1234","changed_paths":["src/team/completion.rs"],"tests_run":true,"tests_passed":true,"artifacts":["docs/workflow.md"],"outcome":"ready_for_review"}
583```"#,
584 )
585 .unwrap();
586
587 let metadata = board::read_workflow_metadata(&task_path).unwrap();
588 assert_eq!(metadata.branch.as_deref(), Some("eng-1-4/task-27"));
589 assert_eq!(
590 metadata.worktree_path.as_deref(),
591 Some(".batty/worktrees/eng-1-4")
592 );
593 assert_eq!(metadata.commit.as_deref(), Some("abc1234"));
594 assert_eq!(metadata.tests_run, Some(true));
595 assert_eq!(metadata.tests_passed, Some(true));
596 assert_eq!(metadata.outcome.as_deref(), Some("ready_for_review"));
597 assert!(metadata.review_blockers.is_empty());
598 }
599
600 #[test]
601 fn send_message_does_not_ingest_failed_test_completion_packet() {
602 let tmp = tempfile::tempdir().unwrap();
603 let tasks_dir = team_config_dir(tmp.path()).join("board").join("tasks");
604 std::fs::create_dir_all(&tasks_dir).unwrap();
605 let task_path = tasks_dir.join("027-completion-packets.md");
606 std::fs::write(
607 &task_path,
608 "---\nid: 27\ntitle: Completion packets\nstatus: review\npriority: medium\nclaimed_by: human\nclass: standard\n---\n\nTask body.\n",
609 )
610 .unwrap();
611
612 send_message(
613 tmp.path(),
614 "architect",
615 r#"Done.
616
617## Completion Packet
618
619```json
620{"task_id":27,"branch":"eng-1-4/task-27","worktree_path":".batty/worktrees/eng-1-4","commit":"abc1234","changed_paths":["src/team/completion.rs"],"tests_run":true,"tests_passed":false,"artifacts":[],"outcome":"ready_for_review"}
621```"#,
622 )
623 .unwrap();
624
625 let metadata = board::read_workflow_metadata(&task_path).unwrap();
626 assert!(metadata.branch.is_none());
627 assert!(metadata.tests_run.is_none());
628 assert!(metadata.review_blockers.is_empty());
629 }
630
631 #[test]
632 fn assign_task_delivers_to_inbox() {
633 let tmp = tempfile::tempdir().unwrap();
634 let _tmux_pane = EnvVarGuard::unset("TMUX_PANE");
635 let _batty_member = EnvVarGuard::unset("BATTY_MEMBER");
636 let id = assign_task(tmp.path(), "eng-1-1", "fix bug").unwrap();
637 assert!(!id.is_empty());
638
639 let root = inbox::inboxes_root(tmp.path());
640 let pending = inbox::pending_messages(&root, "eng-1-1").unwrap();
641 assert_eq!(pending.len(), 1);
642 let expected_from = effective_sender(tmp.path(), None);
643 assert_eq!(pending[0].from, expected_from);
644 assert_eq!(pending[0].to, "eng-1-1");
645 assert_eq!(pending[0].body, "fix bug");
646 assert_eq!(pending[0].msg_type, inbox::MessageType::Assign);
647 }
648
649 #[test]
650 #[serial]
651 fn send_message_ignores_detected_sender_outside_project() {
652 let tmp = tempfile::tempdir().unwrap();
653 let _member = EnvVarGuard::unset("TMUX_PANE");
654 let original_member = std::env::var("BATTY_MEMBER").ok();
655 unsafe {
656 std::env::set_var("BATTY_MEMBER", "foreign-engineer-9-9");
657 }
658
659 send_message(tmp.path(), "architect", "hello").unwrap();
660
661 match original_member.as_deref() {
662 Some(value) => unsafe {
663 std::env::set_var("BATTY_MEMBER", value);
664 },
665 None => unsafe {
666 std::env::remove_var("BATTY_MEMBER");
667 },
668 }
669
670 let root = inbox::inboxes_root(tmp.path());
671 let pending = inbox::pending_messages(&root, "architect").unwrap();
672 assert_eq!(pending.len(), 1);
673 assert_eq!(pending[0].from, "human");
674 }
675
676 #[test]
677 fn resolve_member_name_maps_unique_role_alias_to_instance() {
678 let tmp = tempfile::tempdir().unwrap();
679 write_team_config(
680 tmp.path(),
681 r#"
682name: test
683roles:
684 - name: human
685 role_type: user
686 talks_to:
687 - sam-designer
688 - name: jordan-pm
689 role_type: manager
690 agent: claude
691 instances: 1
692 - name: sam-designer
693 role_type: engineer
694 agent: codex
695 instances: 1
696 talks_to:
697 - jordan-pm
698"#,
699 );
700
701 assert_eq!(
702 resolve_member_name(tmp.path(), "sam-designer").unwrap(),
703 "sam-designer-1-1"
704 );
705 assert_eq!(
706 resolve_member_name(tmp.path(), "sam-designer-1-1").unwrap(),
707 "sam-designer-1-1"
708 );
709 }
710
711 #[test]
712 fn resolve_member_name_rejects_ambiguous_role_alias() {
713 let tmp = tempfile::tempdir().unwrap();
714 write_team_config(
715 tmp.path(),
716 r#"
717name: test
718roles:
719 - name: jordan-pm
720 role_type: manager
721 agent: claude
722 instances: 2
723 - name: sam-designer
724 role_type: engineer
725 agent: codex
726 instances: 1
727 talks_to:
728 - jordan-pm
729"#,
730 );
731
732 let error = resolve_member_name(tmp.path(), "sam-designer")
733 .unwrap_err()
734 .to_string();
735 assert!(error.contains("matches multiple members"));
736 assert!(error.contains("sam-designer-1-1"));
737 assert!(error.contains("sam-designer-2-1"));
738 }
739
740 #[test]
741 #[serial]
742 fn send_message_delivers_to_unique_instance_inbox() {
743 let tmp = tempfile::tempdir().unwrap();
744 let _tmux_pane = EnvVarGuard::unset("TMUX_PANE");
745 let _batty_member = EnvVarGuard::unset("BATTY_MEMBER");
746 write_team_config(
747 tmp.path(),
748 r#"
749name: test
750roles:
751 - name: human
752 role_type: user
753 talks_to:
754 - sam-designer
755 - name: jordan-pm
756 role_type: manager
757 agent: claude
758 instances: 1
759 - name: sam-designer
760 role_type: engineer
761 agent: codex
762 instances: 1
763 talks_to:
764 - jordan-pm
765"#,
766 );
767
768 let original_tmux_pane = std::env::var_os("TMUX_PANE");
769 unsafe {
770 std::env::remove_var("TMUX_PANE");
771 }
772 let send_result = send_message(tmp.path(), "sam-designer", "hello");
773 match original_tmux_pane {
774 Some(value) => unsafe {
775 std::env::set_var("TMUX_PANE", value);
776 },
777 None => unsafe {
778 std::env::remove_var("TMUX_PANE");
779 },
780 }
781 send_result.unwrap();
782
783 let root = inbox::inboxes_root(tmp.path());
784 assert!(
785 inbox::pending_messages(&root, "sam-designer")
786 .unwrap()
787 .is_empty()
788 );
789
790 let pending = inbox::pending_messages(&root, "sam-designer-1-1").unwrap();
791 assert_eq!(pending.len(), 1);
792 assert_eq!(pending[0].to, "sam-designer-1-1");
793 assert_eq!(pending[0].body, "hello");
794 }
795
796 #[test]
797 fn truncate_chars_handles_unicode_boundaries() {
798 let body = "Task #109 confirmed complete on main. I'm available for next assignment.";
799 let truncated = truncate_chars(body, 40);
800 assert!(truncated.ends_with("..."));
801 assert!(truncated.starts_with("Task #109 confirmed complete on main."));
802 }
803
804 #[test]
805 fn format_inbox_listing_shows_most_recent_messages_by_default_limit() {
806 let messages: Vec<_> = (0..25)
807 .map(|idx| {
808 (
809 inbox::InboxMessage {
810 id: format!("msg{idx:05}"),
811 from: "architect".to_string(),
812 to: "black-lead".to_string(),
813 body: format!("message {idx}"),
814 msg_type: inbox::MessageType::Send,
815 timestamp: idx,
816 },
817 true,
818 )
819 })
820 .collect();
821
822 let rendered = format_inbox_listing("black-lead", &messages, Some(20));
823 assert!(rendered.contains("Showing 20 of 25 messages for black-lead."));
824 assert!(!rendered.contains("message 0"));
825 assert!(rendered.contains("message 5"));
826 assert!(rendered.contains("message 24"));
827 assert!(!rendered.contains("msg00005"));
828 assert!(!rendered.contains("msg00024"));
829 }
830
831 #[test]
832 fn format_inbox_listing_allows_showing_all_messages() {
833 let messages: Vec<_> = (0..3)
834 .map(|idx| {
835 (
836 inbox::InboxMessage {
837 id: format!("msg{idx:05}"),
838 from: "architect".to_string(),
839 to: "black-lead".to_string(),
840 body: format!("message {idx}"),
841 msg_type: inbox::MessageType::Send,
842 timestamp: idx,
843 },
844 idx % 2 == 0,
845 )
846 })
847 .collect();
848
849 let rendered = format_inbox_listing("black-lead", &messages, None);
850 assert!(!rendered.contains("Showing 20"));
851 assert!(rendered.contains("REF"));
852 assert!(rendered.contains("BODY"));
853 assert!(rendered.contains("message 0"));
854 assert!(rendered.contains("message 1"));
855 assert!(rendered.contains("message 2"));
856 assert!(!rendered.contains("msg00000"));
857 assert!(!rendered.contains("msg00001"));
858 assert!(!rendered.contains("msg00002"));
859 }
860
861 #[test]
862 fn format_inbox_listing_hides_internal_message_ids() {
863 let messages = vec![(
864 inbox::InboxMessage {
865 id: "1773930387654321.M123456P7890Q42.example".to_string(),
866 from: "architect".to_string(),
867 to: "black-lead".to_string(),
868 body: "message body".to_string(),
869 msg_type: inbox::MessageType::Send,
870 timestamp: 1_773_930_725,
871 },
872 true,
873 )];
874
875 let rendered = format_inbox_listing("black-lead", &messages, None);
876 assert!(rendered.contains("1773930725"));
877 assert!(!rendered.contains("1773930387654321.M123456P7890Q42.example"));
878 assert!(!rendered.contains("ID BODY"));
879 }
880
881 #[test]
882 fn inbox_message_refs_use_timestamp_when_unique() {
883 let messages = vec![(
884 inbox::InboxMessage {
885 id: "msg-1".to_string(),
886 from: "architect".to_string(),
887 to: "black-lead".to_string(),
888 body: "message body".to_string(),
889 msg_type: inbox::MessageType::Send,
890 timestamp: 1_773_930_725,
891 },
892 true,
893 )];
894
895 let refs = inbox_message_refs(&messages);
896 assert_eq!(refs, vec!["1773930725".to_string()]);
897 assert_eq!(
898 resolve_inbox_message_indices(&messages, "1773930725"),
899 vec![0]
900 );
901 }
902
903 #[test]
904 fn inbox_message_refs_suffix_same_second_collisions() {
905 let messages = vec![
906 (
907 inbox::InboxMessage {
908 id: "msg-1".to_string(),
909 from: "architect".to_string(),
910 to: "black-lead".to_string(),
911 body: "first".to_string(),
912 msg_type: inbox::MessageType::Send,
913 timestamp: 1_773_930_725,
914 },
915 true,
916 ),
917 (
918 inbox::InboxMessage {
919 id: "msg-2".to_string(),
920 from: "architect".to_string(),
921 to: "black-lead".to_string(),
922 body: "second".to_string(),
923 msg_type: inbox::MessageType::Send,
924 timestamp: 1_773_930_725,
925 },
926 true,
927 ),
928 ];
929
930 let refs = inbox_message_refs(&messages);
931 assert_eq!(
932 refs,
933 vec!["1773930725-1".to_string(), "1773930725-2".to_string()]
934 );
935 assert!(resolve_inbox_message_indices(&messages, "1773930725").is_empty());
936 assert_eq!(
937 resolve_inbox_message_indices(&messages, "1773930725-1"),
938 vec![0]
939 );
940 assert_eq!(
941 resolve_inbox_message_indices(&messages, "1773930725-2"),
942 vec![1]
943 );
944 }
945
946 #[test]
947 fn format_inbox_digest_empty_inbox() {
948 let rendered = format_inbox_digest("manager", &[], None, None);
949 assert_eq!(rendered, "No messages for manager.\n");
950 }
951
952 #[test]
953 fn format_inbox_digest_shows_category_column() {
954 let messages = vec![(
955 inbox::InboxMessage {
956 id: "msg1".to_string(),
957 from: "eng-1".to_string(),
958 to: "manager".to_string(),
959 body: "Task #42 escalated: critical".to_string(),
960 msg_type: inbox::MessageType::Send,
961 timestamp: 100,
962 },
963 false,
964 )];
965 let rendered = format_inbox_digest("manager", &messages, None, None);
966 assert!(rendered.contains("CATEGORY"));
967 assert!(rendered.contains("ESCALATION"));
968 }
969
970 #[test]
971 fn format_inbox_digest_shows_collapsed_count() {
972 let messages: Vec<_> = (0..3)
973 .map(|i| {
974 (
975 inbox::InboxMessage {
976 id: format!("msg{i}"),
977 from: "daemon".to_string(),
978 to: "eng-1".to_string(),
979 body: "Idle nudge: move forward".to_string(),
980 msg_type: inbox::MessageType::Send,
981 timestamp: 100 + i as u64,
982 },
983 true,
984 )
985 })
986 .collect();
987
988 let rendered = format_inbox_digest("eng-1", &messages, None, None);
989 assert!(rendered.contains("x3"), "should show collapsed count x3");
990 assert!(rendered.contains("nudge"));
991 }
992
993 #[test]
994 fn format_inbox_digest_shows_compression_header() {
995 let messages: Vec<_> = (0..5)
996 .map(|i| {
997 (
998 inbox::InboxMessage {
999 id: format!("msg{i}"),
1000 from: "daemon".to_string(),
1001 to: "eng-1".to_string(),
1002 body: "Idle nudge: move forward".to_string(),
1003 msg_type: inbox::MessageType::Send,
1004 timestamp: 100 + i as u64,
1005 },
1006 true,
1007 )
1008 })
1009 .collect();
1010
1011 let rendered = format_inbox_digest("eng-1", &messages, None, None);
1012 assert!(rendered.contains("Digest: 1 entries from 5 messages"));
1013 assert!(rendered.contains("4 collapsed"));
1014 assert!(rendered.contains("--raw"));
1015 }
1016
1017 #[test]
1018 fn format_inbox_digest_respects_limit() {
1019 let messages: Vec<_> = vec![
1020 (
1021 inbox::InboxMessage {
1022 id: "msg1".to_string(),
1023 from: "eng-1".to_string(),
1024 to: "manager".to_string(),
1025 body: "Task #42 escalated: critical".to_string(),
1026 msg_type: inbox::MessageType::Send,
1027 timestamp: 100,
1028 },
1029 false,
1030 ),
1031 (
1032 inbox::InboxMessage {
1033 id: "msg2".to_string(),
1034 from: "eng-1".to_string(),
1035 to: "manager".to_string(),
1036 body: "Task #42 ready for review".to_string(),
1037 msg_type: inbox::MessageType::Send,
1038 timestamp: 200,
1039 },
1040 true,
1041 ),
1042 (
1043 inbox::InboxMessage {
1044 id: "msg3".to_string(),
1045 from: "daemon".to_string(),
1046 to: "manager".to_string(),
1047 body: "Idle nudge: move forward".to_string(),
1048 msg_type: inbox::MessageType::Send,
1049 timestamp: 300,
1050 },
1051 true,
1052 ),
1053 ];
1054
1055 let rendered = format_inbox_digest("manager", &messages, Some(2), None);
1056 assert!(rendered.contains("Showing 2 of 3 entries"));
1059 assert!(rendered.contains("ESCALATION"));
1060 assert!(rendered.contains("REVIEW"));
1061 assert!(!rendered.contains("nudge"));
1062 }
1063
1064 #[test]
1065 fn format_inbox_digest_prioritizes_manual_review_notice_over_status_when_limited() {
1066 let messages: Vec<_> = vec![
1067 (
1068 inbox::InboxMessage {
1069 id: "msg1".to_string(),
1070 from: "architect".to_string(),
1071 to: "manager".to_string(),
1072 body: "Status update: triage queue is unchanged.".to_string(),
1073 msg_type: inbox::MessageType::Send,
1074 timestamp: 100,
1075 },
1076 true,
1077 ),
1078 (
1079 inbox::InboxMessage {
1080 id: "msg2".to_string(),
1081 from: "eng-1".to_string(),
1082 to: "manager".to_string(),
1083 body: "[eng-1] Task #42 passed tests but requires manual review.\nTitle: Inbox routing"
1084 .to_string(),
1085 msg_type: inbox::MessageType::Send,
1086 timestamp: 200,
1087 },
1088 true,
1089 ),
1090 ];
1091
1092 let rendered = format_inbox_digest("manager", &messages, Some(1), None);
1093 assert!(rendered.contains("REVIEW"));
1094 assert!(rendered.contains("requires manual review"));
1095 assert!(!rendered.contains("Status update: triage queue is unchanged."));
1096 }
1097}