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