Skip to main content

batty_cli/team/
messaging.rs

1//! Message routing, inbox operations, merge, and Telegram setup.
2
3use 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
13/// Resolve a member instance name (e.g. "eng-1-2") to its role definition name
14/// (e.g. "engineer"). Returns the name itself if no config is available.
15fn resolve_role_name(project_root: &Path, member_name: &str) -> String {
16    // "human" is not a member instance — it's the CLI user
17    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    // Fallback: the name might already be a role name
29    member_name.to_string()
30}
31
32/// Resolve a caller-facing role/member name to a concrete member instance.
33///
34/// Examples:
35/// - exact member names pass through unchanged (`sam-designer-1-1`)
36/// - unique role aliases resolve to their single member instance (`sam-designer`)
37/// - ambiguous aliases error and require an explicit member name
38pub(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
70/// Send a message to a role via their Maildir inbox.
71///
72/// The sender is auto-detected from the `@batty_role` tmux pane option
73/// (set during layout). Falls back to "human" if not in a batty pane.
74/// Enforces communication routing rules from team config.
75pub 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    // Enforce routing: check talks_to rules
89    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
113/// Detect who is calling `batty send` by reading the `@batty_role` option
114/// from the current tmux pane.
115pub(crate) fn detect_sender() -> Option<String> {
116    // 1. Check BATTY_MEMBER env var (set by SDK mode shim subprocess)
117    if let Ok(member) = std::env::var("BATTY_MEMBER") {
118        if !member.is_empty() {
119            return Some(member);
120        }
121    }
122
123    // 2. Fall back to tmux pane role detection (PTY mode)
124    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
169/// Assign a task to an engineer via their Maildir inbox.
170pub 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
195/// List inbox messages for a member.
196///
197/// When `raw` is false (default), messages are digested: nudges are
198/// collapsed per sender, status updates per task show latest only, and
199/// results are priority-sorted (escalations first, nudges last).
200/// When `raw` is true, all messages are shown in chronological order.
201pub 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    // Demote escalations whose referenced tasks are already done on
241    // the board so stale-task spam stops inflating the top of the
242    // digest (#612). Raw view is always lossless — this filter only
243    // applies to the digest view.
244    if let Some(board_dir) = board_dir {
245        entries = inbox::demote_stale_escalations(entries, board_dir);
246        // Re-sort after demotion so promoted/demoted categories land
247        // in the right position.
248        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    // Header showing compression ratio
265    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
407/// Read a specific message from a member's inbox by ID, ID prefix, or REF.
408pub 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
439/// Acknowledge (mark delivered) a message in a member's inbox by ID, prefix, or REF.
440pub 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
457/// Purge delivered messages from one inbox or all inboxes.
458pub 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
480/// Merge an engineer's worktree branch.
481pub 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
492/// Run the interactive Telegram setup wizard.
493pub fn setup_telegram(project_root: &Path) -> Result<()> {
494    telegram::setup_telegram(project_root)
495}
496
497/// Run the interactive Discord setup wizard.
498pub fn setup_discord(project_root: &Path) -> Result<()> {
499    discord::setup_discord(project_root)
500}
501
502/// Show Discord connection health from the current team config.
503pub 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        // 3 distinct entries (all different categories), limit 2 shows the
1057        // highest-signal entries first.
1058        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}