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, 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 = from_override
86        .map(str::to_string)
87        .or_else(detect_sender)
88        .unwrap_or_else(|| "human".to_string());
89    let recipient = resolve_member_name(project_root, role)?;
90
91    // Enforce routing: check talks_to rules
92    let config_path = team_config_path(project_root);
93    if config_path.exists() {
94        if let Ok(team_config) = config::TeamConfig::load(&config_path) {
95            let from_role = resolve_role_name(project_root, &from);
96            let to_role = resolve_role_name(project_root, &recipient);
97            if !team_config.can_talk(&from_role, &to_role) {
98                bail!(
99                    "{from} ({from_role}) is not allowed to message {recipient} ({to_role}). \
100                     Check talks_to in team.yaml."
101                );
102            }
103        }
104    }
105
106    let root = inbox::inboxes_root(project_root);
107    let inbox_msg = inbox::InboxMessage::new_send(&from, &recipient, msg);
108    let id = inbox::deliver_to_inbox(&root, &inbox_msg)?;
109    if let Err(error) = completion::ingest_completion_message(project_root, msg) {
110        warn!(from, to = %recipient, error = %error, "failed to ingest completion packet");
111    }
112    info!(to = %recipient, id = %id, "message delivered to inbox");
113    Ok(())
114}
115
116/// Detect who is calling `batty send` by reading the `@batty_role` option
117/// from the current tmux pane.
118pub(crate) fn detect_sender() -> Option<String> {
119    // 1. Check BATTY_MEMBER env var (set by SDK mode shim subprocess)
120    if let Ok(member) = std::env::var("BATTY_MEMBER") {
121        if !member.is_empty() {
122            return Some(member);
123        }
124    }
125
126    // 2. Fall back to tmux pane role detection (PTY mode)
127    let pane_id = std::env::var("TMUX_PANE").ok()?;
128    let output = std::process::Command::new("tmux")
129        .args(["show-options", "-p", "-t", &pane_id, "-v", "@batty_role"])
130        .output()
131        .ok()?;
132    if output.status.success() {
133        let role = String::from_utf8_lossy(&output.stdout).trim().to_string();
134        if !role.is_empty() { Some(role) } else { None }
135    } else {
136        None
137    }
138}
139
140/// Assign a task to an engineer via their Maildir inbox.
141pub fn assign_task(project_root: &Path, engineer: &str, task: &str) -> Result<String> {
142    let from = detect_sender().unwrap_or_else(|| "human".to_string());
143    let recipient = resolve_member_name(project_root, engineer)?;
144
145    let config_path = team_config_path(project_root);
146    if config_path.exists() {
147        if let Ok(team_config) = config::TeamConfig::load(&config_path) {
148            let from_role = resolve_role_name(project_root, &from);
149            let to_role = resolve_role_name(project_root, &recipient);
150            if !team_config.can_talk(&from_role, &to_role) {
151                bail!(
152                    "{from} ({from_role}) is not allowed to assign {recipient} ({to_role}). \
153                     Check talks_to in team.yaml."
154                );
155            }
156        }
157    }
158
159    let root = inbox::inboxes_root(project_root);
160    let msg = inbox::InboxMessage::new_assign(&from, &recipient, task);
161    let id = inbox::deliver_to_inbox(&root, &msg)?;
162    info!(from, engineer = %recipient, task, id = %id, "assignment delivered to inbox");
163    Ok(id)
164}
165
166/// List inbox messages for a member.
167pub fn list_inbox(project_root: &Path, member: &str, limit: Option<usize>) -> Result<()> {
168    let member = resolve_member_name(project_root, member)?;
169    let root = inbox::inboxes_root(project_root);
170    let messages = inbox::all_messages(&root, &member)?;
171    print!("{}", format_inbox_listing(&member, &messages, limit));
172    Ok(())
173}
174
175fn format_inbox_listing(
176    member: &str,
177    messages: &[(inbox::InboxMessage, bool)],
178    limit: Option<usize>,
179) -> String {
180    if messages.is_empty() {
181        return format!("No messages for {member}.\n");
182    }
183
184    let start = match limit {
185        Some(0) => messages.len(),
186        Some(n) => messages.len().saturating_sub(n),
187        None => 0,
188    };
189    let shown = &messages[start..];
190    let refs = inbox_message_refs(messages);
191    let shown_refs = &refs[start..];
192
193    let mut out = String::new();
194    if shown.len() < messages.len() {
195        out.push_str(&format!(
196            "Showing {} of {} messages for {member}. Use `-n <N>` or `--all` to see more.\n",
197            shown.len(),
198            messages.len()
199        ));
200    }
201    out.push_str(&format!(
202        "{:<10} {:<12} {:<12} {:<14} BODY\n",
203        "STATUS", "FROM", "TYPE", "REF"
204    ));
205    out.push_str(&format!("{}\n", "-".repeat(96)));
206    for ((msg, delivered), msg_ref) in shown.iter().zip(shown_refs.iter()) {
207        let status = if *delivered { "delivered" } else { "pending" };
208        let body_short = truncate_chars(&msg.body, INBOX_BODY_PREVIEW_CHARS);
209        out.push_str(&format!(
210            "{:<10} {:<12} {:<12} {:<14} {}\n",
211            status,
212            msg.from,
213            format!("{:?}", msg.msg_type).to_lowercase(),
214            msg_ref,
215            body_short,
216        ));
217    }
218    out
219}
220
221fn inbox_message_refs(messages: &[(inbox::InboxMessage, bool)]) -> Vec<String> {
222    let mut totals = HashMap::new();
223    for (msg, _) in messages {
224        *totals.entry(msg.timestamp).or_insert(0usize) += 1;
225    }
226
227    let mut seen = HashMap::new();
228    messages
229        .iter()
230        .map(|(msg, _)| {
231            let ordinal = seen.entry(msg.timestamp).or_insert(0usize);
232            *ordinal += 1;
233            if totals.get(&msg.timestamp).copied().unwrap_or(0) <= 1 {
234                msg.timestamp.to_string()
235            } else {
236                format!("{}-{}", msg.timestamp, ordinal)
237            }
238        })
239        .collect()
240}
241
242fn resolve_inbox_message_indices(
243    messages: &[(inbox::InboxMessage, bool)],
244    selector: &str,
245) -> Vec<usize> {
246    let refs = inbox_message_refs(messages);
247    messages
248        .iter()
249        .enumerate()
250        .filter_map(|(idx, (msg, _))| {
251            if msg.id == selector || msg.id.starts_with(selector) || refs[idx] == selector {
252                Some(idx)
253            } else {
254                None
255            }
256        })
257        .collect()
258}
259
260fn truncate_chars(input: &str, max_chars: usize) -> String {
261    if input.chars().count() <= max_chars {
262        return input.to_string();
263    }
264    let mut truncated: String = input.chars().take(max_chars).collect();
265    truncated.push_str("...");
266    truncated
267}
268
269/// Read a specific message from a member's inbox by ID, ID prefix, or REF.
270pub fn read_message(project_root: &Path, member: &str, id: &str) -> Result<()> {
271    let member = resolve_member_name(project_root, member)?;
272    let root = inbox::inboxes_root(project_root);
273    let messages = inbox::all_messages(&root, &member)?;
274
275    let matching = resolve_inbox_message_indices(&messages, id);
276
277    match matching.len() {
278        0 => bail!("no message matching '{id}' in {member}'s inbox"),
279        1 => {
280            let (msg, delivered) = &messages[matching[0]];
281            let status = if *delivered { "delivered" } else { "pending" };
282            println!("ID:     {}", msg.id);
283            println!("From:   {}", msg.from);
284            println!("To:     {}", msg.to);
285            println!("Type:   {:?}", msg.msg_type);
286            println!("Status: {status}");
287            println!("Time:   {}", msg.timestamp);
288            println!();
289            println!("{}", msg.body);
290        }
291        n => {
292            bail!(
293                "'{id}' matches {n} messages — use a longer prefix or the REF column from `batty inbox`"
294            );
295        }
296    }
297
298    Ok(())
299}
300
301/// Acknowledge (mark delivered) a message in a member's inbox by ID, prefix, or REF.
302pub fn ack_message(project_root: &Path, member: &str, id: &str) -> Result<()> {
303    let member = resolve_member_name(project_root, member)?;
304    let root = inbox::inboxes_root(project_root);
305    let messages = inbox::all_messages(&root, &member)?;
306    let matching = resolve_inbox_message_indices(&messages, id);
307    let resolved_id = match matching.len() {
308        0 => bail!("no message matching '{id}' in {member}'s inbox"),
309        1 => messages[matching[0]].0.id.clone(),
310        n => bail!(
311            "'{id}' matches {n} messages — use a longer prefix or the REF column from `batty inbox`"
312        ),
313    };
314    inbox::mark_delivered(&root, &member, &resolved_id)?;
315    info!(member, id = %resolved_id, "message acknowledged");
316    Ok(())
317}
318
319/// Purge delivered messages from one inbox or all inboxes.
320pub fn purge_inbox(
321    project_root: &Path,
322    member: Option<&str>,
323    all_roles: bool,
324    before: Option<u64>,
325    purge_all: bool,
326) -> Result<inbox::InboxPurgeSummary> {
327    if !purge_all && before.is_none() {
328        bail!("use `--all` or `--before <unix-timestamp>` with `batty inbox purge`");
329    }
330
331    let root = inbox::inboxes_root(project_root);
332    if all_roles {
333        return inbox::purge_delivered_messages_for_all(&root, before, purge_all);
334    }
335
336    let member = member.context("member is required unless using `--all-roles`")?;
337    let member = resolve_member_name(project_root, member)?;
338    let messages = inbox::purge_delivered_messages(&root, &member, before, purge_all)?;
339    Ok(inbox::InboxPurgeSummary { roles: 1, messages })
340}
341
342/// Merge an engineer's worktree branch.
343pub fn merge_worktree(project_root: &Path, engineer: &str) -> Result<()> {
344    let engineer = resolve_member_name(project_root, engineer)?;
345    match merge::merge_engineer_branch(project_root, &engineer)? {
346        merge::MergeOutcome::Success => Ok(()),
347        merge::MergeOutcome::RebaseConflict(stderr) => {
348            bail!("merge blocked by rebase conflict: {stderr}")
349        }
350        merge::MergeOutcome::MergeFailure(stderr) => bail!("merge failed: {stderr}"),
351    }
352}
353
354/// Run the interactive Telegram setup wizard.
355pub fn setup_telegram(project_root: &Path) -> Result<()> {
356    telegram::setup_telegram(project_root)
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use crate::team::{board, inbox, team_config_dir, team_config_path};
363    use serial_test::serial;
364
365    struct EnvVarGuard {
366        key: &'static str,
367        original: Option<String>,
368    }
369
370    impl EnvVarGuard {
371        fn unset(key: &'static str) -> Self {
372            let original = std::env::var(key).ok();
373            unsafe {
374                std::env::remove_var(key);
375            }
376            Self { key, original }
377        }
378    }
379
380    impl Drop for EnvVarGuard {
381        fn drop(&mut self) {
382            match self.original.as_deref() {
383                Some(value) => unsafe {
384                    std::env::set_var(self.key, value);
385                },
386                None => unsafe {
387                    std::env::remove_var(self.key);
388                },
389            }
390        }
391    }
392
393    fn write_team_config(project_root: &Path, yaml: &str) {
394        std::fs::create_dir_all(team_config_dir(project_root)).unwrap();
395        std::fs::write(team_config_path(project_root), yaml).unwrap();
396    }
397
398    #[test]
399    fn send_message_delivers_to_inbox() {
400        let tmp = tempfile::tempdir().unwrap();
401        send_message(tmp.path(), "architect", "hello").unwrap();
402
403        let root = inbox::inboxes_root(tmp.path());
404        let pending = inbox::pending_messages(&root, "architect").unwrap();
405        assert_eq!(pending.len(), 1);
406        // detect_sender() returns the tmux pane role if running inside a batty
407        // session, or "human" otherwise. Accept either.
408        let expected_from = detect_sender().unwrap_or_else(|| "human".to_string());
409        assert_eq!(pending[0].from, expected_from);
410        assert_eq!(pending[0].to, "architect");
411        assert_eq!(pending[0].body, "hello");
412    }
413
414    #[test]
415    fn send_message_ingests_completion_packet_into_workflow_metadata() {
416        let tmp = tempfile::tempdir().unwrap();
417        let tasks_dir = team_config_dir(tmp.path()).join("board").join("tasks");
418        std::fs::create_dir_all(&tasks_dir).unwrap();
419        let task_path = tasks_dir.join("027-completion-packets.md");
420        std::fs::write(
421            &task_path,
422            "---\nid: 27\ntitle: Completion packets\nstatus: review\npriority: medium\nclaimed_by: human\nclass: standard\n---\n\nTask body.\n",
423        )
424        .unwrap();
425
426        send_message(
427            tmp.path(),
428            "architect",
429            r#"Done.
430
431## Completion Packet
432
433```json
434{"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"}
435```"#,
436        )
437        .unwrap();
438
439        let metadata = board::read_workflow_metadata(&task_path).unwrap();
440        assert_eq!(metadata.branch.as_deref(), Some("eng-1-4/task-27"));
441        assert_eq!(
442            metadata.worktree_path.as_deref(),
443            Some(".batty/worktrees/eng-1-4")
444        );
445        assert_eq!(metadata.commit.as_deref(), Some("abc1234"));
446        assert_eq!(metadata.tests_run, Some(true));
447        assert_eq!(metadata.tests_passed, Some(true));
448        assert_eq!(metadata.outcome.as_deref(), Some("ready_for_review"));
449        assert!(metadata.review_blockers.is_empty());
450    }
451
452    #[test]
453    fn assign_task_delivers_to_inbox() {
454        let tmp = tempfile::tempdir().unwrap();
455        let id = assign_task(tmp.path(), "eng-1-1", "fix bug").unwrap();
456        assert!(!id.is_empty());
457
458        let root = inbox::inboxes_root(tmp.path());
459        let pending = inbox::pending_messages(&root, "eng-1-1").unwrap();
460        assert_eq!(pending.len(), 1);
461        let expected_from = detect_sender().unwrap_or_else(|| "human".to_string());
462        assert_eq!(pending[0].from, expected_from);
463        assert_eq!(pending[0].to, "eng-1-1");
464        assert_eq!(pending[0].body, "fix bug");
465        assert_eq!(pending[0].msg_type, inbox::MessageType::Assign);
466    }
467
468    #[test]
469    fn resolve_member_name_maps_unique_role_alias_to_instance() {
470        let tmp = tempfile::tempdir().unwrap();
471        write_team_config(
472            tmp.path(),
473            r#"
474name: test
475roles:
476  - name: human
477    role_type: user
478    talks_to:
479      - sam-designer
480  - name: jordan-pm
481    role_type: manager
482    agent: claude
483    instances: 1
484  - name: sam-designer
485    role_type: engineer
486    agent: codex
487    instances: 1
488    talks_to:
489      - jordan-pm
490"#,
491        );
492
493        assert_eq!(
494            resolve_member_name(tmp.path(), "sam-designer").unwrap(),
495            "sam-designer-1-1"
496        );
497        assert_eq!(
498            resolve_member_name(tmp.path(), "sam-designer-1-1").unwrap(),
499            "sam-designer-1-1"
500        );
501    }
502
503    #[test]
504    fn resolve_member_name_rejects_ambiguous_role_alias() {
505        let tmp = tempfile::tempdir().unwrap();
506        write_team_config(
507            tmp.path(),
508            r#"
509name: test
510roles:
511  - name: jordan-pm
512    role_type: manager
513    agent: claude
514    instances: 2
515  - name: sam-designer
516    role_type: engineer
517    agent: codex
518    instances: 1
519    talks_to:
520      - jordan-pm
521"#,
522        );
523
524        let error = resolve_member_name(tmp.path(), "sam-designer")
525            .unwrap_err()
526            .to_string();
527        assert!(error.contains("matches multiple members"));
528        assert!(error.contains("sam-designer-1-1"));
529        assert!(error.contains("sam-designer-2-1"));
530    }
531
532    #[test]
533    #[serial]
534    fn send_message_delivers_to_unique_instance_inbox() {
535        let tmp = tempfile::tempdir().unwrap();
536        let _tmux_pane = EnvVarGuard::unset("TMUX_PANE");
537        write_team_config(
538            tmp.path(),
539            r#"
540name: test
541roles:
542  - name: human
543    role_type: user
544    talks_to:
545      - sam-designer
546  - name: jordan-pm
547    role_type: manager
548    agent: claude
549    instances: 1
550  - name: sam-designer
551    role_type: engineer
552    agent: codex
553    instances: 1
554    talks_to:
555      - jordan-pm
556"#,
557        );
558
559        let original_tmux_pane = std::env::var_os("TMUX_PANE");
560        unsafe {
561            std::env::remove_var("TMUX_PANE");
562        }
563        let send_result = send_message(tmp.path(), "sam-designer", "hello");
564        match original_tmux_pane {
565            Some(value) => unsafe {
566                std::env::set_var("TMUX_PANE", value);
567            },
568            None => unsafe {
569                std::env::remove_var("TMUX_PANE");
570            },
571        }
572        send_result.unwrap();
573
574        let root = inbox::inboxes_root(tmp.path());
575        assert!(
576            inbox::pending_messages(&root, "sam-designer")
577                .unwrap()
578                .is_empty()
579        );
580
581        let pending = inbox::pending_messages(&root, "sam-designer-1-1").unwrap();
582        assert_eq!(pending.len(), 1);
583        assert_eq!(pending[0].to, "sam-designer-1-1");
584        assert_eq!(pending[0].body, "hello");
585    }
586
587    #[test]
588    fn truncate_chars_handles_unicode_boundaries() {
589        let body = "Task #109 confirmed complete on main. I'm available for next assignment.";
590        let truncated = truncate_chars(body, 40);
591        assert!(truncated.ends_with("..."));
592        assert!(truncated.starts_with("Task #109 confirmed complete on main."));
593    }
594
595    #[test]
596    fn format_inbox_listing_shows_most_recent_messages_by_default_limit() {
597        let messages: Vec<_> = (0..25)
598            .map(|idx| {
599                (
600                    inbox::InboxMessage {
601                        id: format!("msg{idx:05}"),
602                        from: "architect".to_string(),
603                        to: "black-lead".to_string(),
604                        body: format!("message {idx}"),
605                        msg_type: inbox::MessageType::Send,
606                        timestamp: idx,
607                    },
608                    true,
609                )
610            })
611            .collect();
612
613        let rendered = format_inbox_listing("black-lead", &messages, Some(20));
614        assert!(rendered.contains("Showing 20 of 25 messages for black-lead."));
615        assert!(!rendered.contains("message 0"));
616        assert!(rendered.contains("message 5"));
617        assert!(rendered.contains("message 24"));
618        assert!(!rendered.contains("msg00005"));
619        assert!(!rendered.contains("msg00024"));
620    }
621
622    #[test]
623    fn format_inbox_listing_allows_showing_all_messages() {
624        let messages: Vec<_> = (0..3)
625            .map(|idx| {
626                (
627                    inbox::InboxMessage {
628                        id: format!("msg{idx:05}"),
629                        from: "architect".to_string(),
630                        to: "black-lead".to_string(),
631                        body: format!("message {idx}"),
632                        msg_type: inbox::MessageType::Send,
633                        timestamp: idx,
634                    },
635                    idx % 2 == 0,
636                )
637            })
638            .collect();
639
640        let rendered = format_inbox_listing("black-lead", &messages, None);
641        assert!(!rendered.contains("Showing 20"));
642        assert!(rendered.contains("REF"));
643        assert!(rendered.contains("BODY"));
644        assert!(rendered.contains("message 0"));
645        assert!(rendered.contains("message 1"));
646        assert!(rendered.contains("message 2"));
647        assert!(!rendered.contains("msg00000"));
648        assert!(!rendered.contains("msg00001"));
649        assert!(!rendered.contains("msg00002"));
650    }
651
652    #[test]
653    fn format_inbox_listing_hides_internal_message_ids() {
654        let messages = vec![(
655            inbox::InboxMessage {
656                id: "1773930387654321.M123456P7890Q42.example".to_string(),
657                from: "architect".to_string(),
658                to: "black-lead".to_string(),
659                body: "message body".to_string(),
660                msg_type: inbox::MessageType::Send,
661                timestamp: 1_773_930_725,
662            },
663            true,
664        )];
665
666        let rendered = format_inbox_listing("black-lead", &messages, None);
667        assert!(rendered.contains("1773930725"));
668        assert!(!rendered.contains("1773930387654321.M123456P7890Q42.example"));
669        assert!(!rendered.contains("ID BODY"));
670    }
671
672    #[test]
673    fn inbox_message_refs_use_timestamp_when_unique() {
674        let messages = vec![(
675            inbox::InboxMessage {
676                id: "msg-1".to_string(),
677                from: "architect".to_string(),
678                to: "black-lead".to_string(),
679                body: "message body".to_string(),
680                msg_type: inbox::MessageType::Send,
681                timestamp: 1_773_930_725,
682            },
683            true,
684        )];
685
686        let refs = inbox_message_refs(&messages);
687        assert_eq!(refs, vec!["1773930725".to_string()]);
688        assert_eq!(
689            resolve_inbox_message_indices(&messages, "1773930725"),
690            vec![0]
691        );
692    }
693
694    #[test]
695    fn inbox_message_refs_suffix_same_second_collisions() {
696        let messages = vec![
697            (
698                inbox::InboxMessage {
699                    id: "msg-1".to_string(),
700                    from: "architect".to_string(),
701                    to: "black-lead".to_string(),
702                    body: "first".to_string(),
703                    msg_type: inbox::MessageType::Send,
704                    timestamp: 1_773_930_725,
705                },
706                true,
707            ),
708            (
709                inbox::InboxMessage {
710                    id: "msg-2".to_string(),
711                    from: "architect".to_string(),
712                    to: "black-lead".to_string(),
713                    body: "second".to_string(),
714                    msg_type: inbox::MessageType::Send,
715                    timestamp: 1_773_930_725,
716                },
717                true,
718            ),
719        ];
720
721        let refs = inbox_message_refs(&messages);
722        assert_eq!(
723            refs,
724            vec!["1773930725-1".to_string(), "1773930725-2".to_string()]
725        );
726        assert!(resolve_inbox_message_indices(&messages, "1773930725").is_empty());
727        assert_eq!(
728            resolve_inbox_message_indices(&messages, "1773930725-1"),
729            vec![0]
730        );
731        assert_eq!(
732            resolve_inbox_message_indices(&messages, "1773930725-2"),
733            vec![1]
734        );
735    }
736}