use std::collections::HashMap;
use std::path::Path;
use anyhow::{Context, Result, bail};
use tracing::{info, warn};
use super::{completion, config, discord, hierarchy, inbox, merge, team_config_path, telegram};
const INBOX_BODY_PREVIEW_CHARS: usize = 140;
fn resolve_role_name(project_root: &Path, member_name: &str) -> String {
if matches!(member_name, "human" | "daemon") {
return member_name.to_string();
}
let config_path = team_config_path(project_root);
if let Ok(team_config) = config::TeamConfig::load(&config_path) {
if let Ok(members) = hierarchy::resolve_hierarchy(&team_config) {
if let Some(m) = members.iter().find(|m| m.name == member_name) {
return m.role_name.clone();
}
}
}
member_name.to_string()
}
pub(crate) fn resolve_member_name(project_root: &Path, member_name: &str) -> Result<String> {
if matches!(member_name, "human" | "daemon") {
return Ok(member_name.to_string());
}
let config_path = team_config_path(project_root);
if let Ok(team_config) = config::TeamConfig::load(&config_path) {
if let Ok(members) = hierarchy::resolve_hierarchy(&team_config) {
if let Some(member) = members.iter().find(|m| m.name == member_name) {
return Ok(member.name.clone());
}
let matches: Vec<String> = members
.iter()
.filter(|m| m.role_name == member_name)
.map(|m| m.name.clone())
.collect();
return match matches.len() {
0 => Ok(member_name.to_string()),
1 => Ok(matches[0].clone()),
_ => bail!(
"'{member_name}' matches multiple members: {}. Use the explicit member name.",
matches.join(", ")
),
};
}
}
Ok(member_name.to_string())
}
pub fn send_message(project_root: &Path, role: &str, msg: &str) -> Result<()> {
send_message_as(project_root, None, role, msg)
}
pub fn send_message_as(
project_root: &Path,
from_override: Option<&str>,
role: &str,
msg: &str,
) -> Result<()> {
let from = effective_sender(project_root, from_override);
let recipient = resolve_member_name(project_root, role)?;
let config_path = team_config_path(project_root);
if config_path.exists() {
if let Ok(team_config) = config::TeamConfig::load(&config_path) {
let from_role = resolve_role_name(project_root, &from);
let to_role = resolve_role_name(project_root, &recipient);
if !team_config.can_talk(&from_role, &to_role) {
bail!(
"{from} ({from_role}) is not allowed to message {recipient} ({to_role}). \
Check talks_to in team.yaml."
);
}
}
}
let root = inbox::inboxes_root(project_root);
let inbox_msg = inbox::InboxMessage::new_send(&from, &recipient, msg);
let id = inbox::deliver_to_inbox(&root, &inbox_msg)?;
if let Err(error) = completion::ingest_completion_message(project_root, msg) {
warn!(from, to = %recipient, error = %error, "failed to ingest completion packet");
}
info!(to = %recipient, id = %id, "message delivered to inbox");
Ok(())
}
pub(crate) fn detect_sender() -> Option<String> {
if let Ok(member) = std::env::var("BATTY_MEMBER") {
if !member.is_empty() {
return Some(member);
}
}
let pane_id = std::env::var("TMUX_PANE").ok()?;
let output = crate::tmux::run_tmux_with_timeout(
["show-options", "-p", "-t", &pane_id, "-v", "@batty_role"],
"show-options @batty_role",
Some(&pane_id),
)
.ok()?;
if output.status.success() {
let role = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !role.is_empty() { Some(role) } else { None }
} else {
None
}
}
fn effective_sender(project_root: &Path, from_override: Option<&str>) -> String {
let candidate = from_override
.map(str::to_string)
.or_else(detect_sender)
.unwrap_or_else(|| "human".to_string());
if sender_belongs_to_project(project_root, &candidate) {
candidate
} else {
"human".to_string()
}
}
fn sender_belongs_to_project(project_root: &Path, sender: &str) -> bool {
if matches!(sender, "human" | "daemon") {
return true;
}
let config_path = team_config_path(project_root);
let Ok(team_config) = config::TeamConfig::load(&config_path) else {
return false;
};
if team_config.roles.iter().any(|role| role.name == sender) {
return true;
}
hierarchy::resolve_hierarchy(&team_config)
.map(|members| members.iter().any(|member| member.name == sender))
.unwrap_or(false)
}
pub fn assign_task(project_root: &Path, engineer: &str, task: &str) -> Result<String> {
let from = effective_sender(project_root, None);
let recipient = resolve_member_name(project_root, engineer)?;
let config_path = team_config_path(project_root);
if config_path.exists() {
if let Ok(team_config) = config::TeamConfig::load(&config_path) {
let from_role = resolve_role_name(project_root, &from);
let to_role = resolve_role_name(project_root, &recipient);
if !team_config.can_talk(&from_role, &to_role) {
bail!(
"{from} ({from_role}) is not allowed to assign {recipient} ({to_role}). \
Check talks_to in team.yaml."
);
}
}
}
let board_dir = board_dir_for(project_root);
let tasks_dir = board_dir.join("tasks");
if tasks_dir.exists() {
let active: Vec<crate::task::Task> = crate::task::load_tasks_from_dir(&tasks_dir)
.unwrap_or_default()
.into_iter()
.filter(|t| {
t.claimed_by.as_deref() == Some(recipient.as_str())
&& matches!(t.status.as_str(), "in-progress" | "review")
})
.collect();
if !active.is_empty() {
let summary = active
.iter()
.map(|t| format!("#{} ({}: {})", t.id, t.status, t.title))
.collect::<Vec<_>>()
.join(", ");
bail!(
"Assignment to {recipient} rejected (WIP limit): engineer already has {} active item(s): {summary}. \
Merge or complete the current work first, then re-assign.",
active.len()
);
}
}
let root = inbox::inboxes_root(project_root);
let msg = inbox::InboxMessage::new_assign(&from, &recipient, task);
let id = inbox::deliver_to_inbox(&root, &msg)?;
info!(from, engineer = %recipient, task, id = %id, "assignment delivered to inbox");
Ok(id)
}
pub fn list_inbox(
project_root: &Path,
member: &str,
limit: Option<usize>,
raw: bool,
) -> Result<()> {
let member = resolve_member_name(project_root, member)?;
let root = inbox::inboxes_root(project_root);
let messages = inbox::all_messages(&root, &member)?;
if raw {
print!("{}", format_inbox_listing(&member, &messages, limit));
} else {
let board_dir = board_dir_for(project_root);
print!(
"{}",
format_inbox_digest(&member, &messages, limit, Some(&board_dir))
);
}
Ok(())
}
fn board_dir_for(project_root: &Path) -> std::path::PathBuf {
project_root
.join(".batty")
.join("team_config")
.join("board")
}
fn format_inbox_digest(
member: &str,
messages: &[(inbox::InboxMessage, bool)],
limit: Option<usize>,
board_dir: Option<&std::path::Path>,
) -> String {
if messages.is_empty() {
return format!("No messages for {member}.\n");
}
let (mut entries, raw_count) = inbox::digest_messages(messages);
if let Some(board_dir) = board_dir {
entries = inbox::demote_stale_escalations(entries, board_dir);
entries.sort_by(|a, b| {
a.category
.cmp(&b.category)
.then_with(|| b.message.timestamp.cmp(&a.message.timestamp))
});
}
let digest_count = entries.len();
let shown = match limit {
Some(0) => &entries[..0],
Some(n) => &entries[..n.min(entries.len())],
None => &entries[..],
};
let mut out = String::new();
let collapsed = raw_count.saturating_sub(digest_count);
if collapsed > 0 {
out.push_str(&format!(
"Digest: {digest_count} entries from {raw_count} messages ({collapsed} collapsed). Use `--raw` for raw view.\n",
));
}
if shown.len() < entries.len() {
out.push_str(&format!(
"Showing {} of {} entries. Use `-n <N>` or `--all` to see more.\n",
shown.len(),
entries.len()
));
}
out.push_str(&format!(
"{:<12} {:<10} {:<12} {:<6} BODY\n",
"CATEGORY", "STATUS", "FROM", "COUNT"
));
out.push_str(&format!("{}\n", "-".repeat(96)));
for entry in shown {
let cat_label = match entry.category {
inbox::MessageCategory::Escalation => "ESCALATION",
inbox::MessageCategory::ReviewRequest => "REVIEW",
inbox::MessageCategory::DispatchAction => "DISPATCH",
inbox::MessageCategory::CompletionAction => "COMPLETION",
inbox::MessageCategory::Blocker => "BLOCKER",
inbox::MessageCategory::Status => "status",
inbox::MessageCategory::Nudge => "nudge",
inbox::MessageCategory::Reminder => "reminder",
inbox::MessageCategory::Acknowledgement => "ack",
};
let status = if entry.delivered {
"delivered"
} else {
"pending"
};
let count_str = if entry.collapsed_count > 1 {
format!("x{}", entry.collapsed_count)
} else {
String::new()
};
let body_short = truncate_chars(&entry.message.body, INBOX_BODY_PREVIEW_CHARS);
out.push_str(&format!(
"{:<12} {:<10} {:<12} {:<6} {}\n",
cat_label, status, entry.message.from, count_str, body_short,
));
}
out
}
fn format_inbox_listing(
member: &str,
messages: &[(inbox::InboxMessage, bool)],
limit: Option<usize>,
) -> String {
if messages.is_empty() {
return format!("No messages for {member}.\n");
}
let start = match limit {
Some(0) => messages.len(),
Some(n) => messages.len().saturating_sub(n),
None => 0,
};
let shown = &messages[start..];
let refs = inbox_message_refs(messages);
let shown_refs = &refs[start..];
let mut out = String::new();
if shown.len() < messages.len() {
out.push_str(&format!(
"Showing {} of {} messages for {member}. Use `-n <N>` or `--all` to see more.\n",
shown.len(),
messages.len()
));
}
out.push_str(&format!(
"{:<10} {:<12} {:<12} {:<14} BODY\n",
"STATUS", "FROM", "TYPE", "REF"
));
out.push_str(&format!("{}\n", "-".repeat(96)));
for ((msg, delivered), msg_ref) in shown.iter().zip(shown_refs.iter()) {
let status = if *delivered { "delivered" } else { "pending" };
let body_short = truncate_chars(&msg.body, INBOX_BODY_PREVIEW_CHARS);
out.push_str(&format!(
"{:<10} {:<12} {:<12} {:<14} {}\n",
status,
msg.from,
format!("{:?}", msg.msg_type).to_lowercase(),
msg_ref,
body_short,
));
}
out
}
fn inbox_message_refs(messages: &[(inbox::InboxMessage, bool)]) -> Vec<String> {
let mut totals = HashMap::new();
for (msg, _) in messages {
*totals.entry(msg.timestamp).or_insert(0usize) += 1;
}
let mut seen = HashMap::new();
messages
.iter()
.map(|(msg, _)| {
let ordinal = seen.entry(msg.timestamp).or_insert(0usize);
*ordinal += 1;
if totals.get(&msg.timestamp).copied().unwrap_or(0) <= 1 {
msg.timestamp.to_string()
} else {
format!("{}-{}", msg.timestamp, ordinal)
}
})
.collect()
}
fn resolve_inbox_message_indices(
messages: &[(inbox::InboxMessage, bool)],
selector: &str,
) -> Vec<usize> {
let refs = inbox_message_refs(messages);
messages
.iter()
.enumerate()
.filter_map(|(idx, (msg, _))| {
if msg.id == selector || msg.id.starts_with(selector) || refs[idx] == selector {
Some(idx)
} else {
None
}
})
.collect()
}
fn truncate_chars(input: &str, max_chars: usize) -> String {
if input.chars().count() <= max_chars {
return input.to_string();
}
let mut truncated: String = input.chars().take(max_chars).collect();
truncated.push_str("...");
truncated
}
pub fn read_message(project_root: &Path, member: &str, id: &str) -> Result<()> {
let member = resolve_member_name(project_root, member)?;
let root = inbox::inboxes_root(project_root);
let messages = inbox::all_messages(&root, &member)?;
let matching = resolve_inbox_message_indices(&messages, id);
match matching.len() {
0 => bail!("no message matching '{id}' in {member}'s inbox"),
1 => {
let (msg, delivered) = &messages[matching[0]];
let status = if *delivered { "delivered" } else { "pending" };
println!("ID: {}", msg.id);
println!("From: {}", msg.from);
println!("To: {}", msg.to);
println!("Type: {:?}", msg.msg_type);
println!("Status: {status}");
println!("Time: {}", msg.timestamp);
println!();
println!("{}", msg.body);
}
n => {
bail!(
"'{id}' matches {n} messages — use a longer prefix or the REF column from `batty inbox`"
);
}
}
Ok(())
}
pub fn ack_message(project_root: &Path, member: &str, id: &str) -> Result<()> {
let member = resolve_member_name(project_root, member)?;
let root = inbox::inboxes_root(project_root);
let messages = inbox::all_messages(&root, &member)?;
let matching = resolve_inbox_message_indices(&messages, id);
let resolved_id = match matching.len() {
0 => bail!("no message matching '{id}' in {member}'s inbox"),
1 => messages[matching[0]].0.id.clone(),
n => bail!(
"'{id}' matches {n} messages — use a longer prefix or the REF column from `batty inbox`"
),
};
inbox::mark_delivered(&root, &member, &resolved_id)?;
info!(member, id = %resolved_id, "message acknowledged");
Ok(())
}
pub fn purge_inbox(
project_root: &Path,
member: Option<&str>,
all_roles: bool,
before: Option<u64>,
purge_all: bool,
) -> Result<inbox::InboxPurgeSummary> {
if !purge_all && before.is_none() {
bail!("use `--all` or `--before <unix-timestamp>` with `batty inbox purge`");
}
let root = inbox::inboxes_root(project_root);
if all_roles {
return inbox::purge_delivered_messages_for_all(&root, before, purge_all);
}
let member = member.context("member is required unless using `--all-roles`")?;
let member = resolve_member_name(project_root, member)?;
let messages = inbox::purge_delivered_messages(&root, &member, before, purge_all)?;
Ok(inbox::InboxPurgeSummary { roles: 1, messages })
}
pub fn merge_worktree(project_root: &Path, engineer: &str) -> Result<()> {
let engineer = resolve_member_name(project_root, engineer)?;
match merge::merge_engineer_branch(project_root, &engineer)? {
merge::MergeOutcome::Success(_) => Ok(()),
merge::MergeOutcome::RebaseConflict(stderr) => {
bail!("merge blocked by rebase conflict: {stderr}")
}
merge::MergeOutcome::MergeFailure(stderr) => bail!("merge failed: {stderr}"),
}
}
pub fn setup_telegram(project_root: &Path) -> Result<()> {
telegram::setup_telegram(project_root)
}
pub fn setup_discord(project_root: &Path) -> Result<()> {
discord::setup_discord(project_root)
}
pub fn discord_status(project_root: &Path) -> Result<()> {
discord::discord_status(project_root)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::team::{board, inbox, team_config_dir, team_config_path};
use serial_test::serial;
struct EnvVarGuard {
key: &'static str,
original: Option<String>,
}
impl EnvVarGuard {
fn unset(key: &'static str) -> Self {
let original = std::env::var(key).ok();
unsafe {
std::env::remove_var(key);
}
Self { key, original }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match self.original.as_deref() {
Some(value) => unsafe {
std::env::set_var(self.key, value);
},
None => unsafe {
std::env::remove_var(self.key);
},
}
}
}
fn write_team_config(project_root: &Path, yaml: &str) {
std::fs::create_dir_all(team_config_dir(project_root)).unwrap();
std::fs::write(team_config_path(project_root), yaml).unwrap();
}
#[test]
fn send_message_delivers_to_inbox() {
let tmp = tempfile::tempdir().unwrap();
let _tmux_pane = EnvVarGuard::unset("TMUX_PANE");
let _batty_member = EnvVarGuard::unset("BATTY_MEMBER");
send_message(tmp.path(), "architect", "hello").unwrap();
let root = inbox::inboxes_root(tmp.path());
let pending = inbox::pending_messages(&root, "architect").unwrap();
assert_eq!(pending.len(), 1);
let expected_from = effective_sender(tmp.path(), None);
assert_eq!(pending[0].from, expected_from);
assert_eq!(pending[0].to, "architect");
assert_eq!(pending[0].body, "hello");
}
#[test]
fn send_message_ingests_completion_packet_into_workflow_metadata() {
let tmp = tempfile::tempdir().unwrap();
let tasks_dir = team_config_dir(tmp.path()).join("board").join("tasks");
std::fs::create_dir_all(&tasks_dir).unwrap();
let task_path = tasks_dir.join("027-completion-packets.md");
std::fs::write(
&task_path,
"---\nid: 27\ntitle: Completion packets\nstatus: review\npriority: medium\nclaimed_by: human\nclass: standard\n---\n\nTask body.\n",
)
.unwrap();
send_message(
tmp.path(),
"architect",
r#"Done.
## Completion Packet
```json
{"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"}
```"#,
)
.unwrap();
let metadata = board::read_workflow_metadata(&task_path).unwrap();
assert_eq!(metadata.branch.as_deref(), Some("eng-1-4/task-27"));
assert_eq!(
metadata.worktree_path.as_deref(),
Some(".batty/worktrees/eng-1-4")
);
assert_eq!(metadata.commit.as_deref(), Some("abc1234"));
assert_eq!(metadata.tests_run, Some(true));
assert_eq!(metadata.tests_passed, Some(true));
assert_eq!(metadata.outcome.as_deref(), Some("ready_for_review"));
assert!(metadata.review_blockers.is_empty());
}
#[test]
fn send_message_does_not_ingest_failed_test_completion_packet() {
let tmp = tempfile::tempdir().unwrap();
let tasks_dir = team_config_dir(tmp.path()).join("board").join("tasks");
std::fs::create_dir_all(&tasks_dir).unwrap();
let task_path = tasks_dir.join("027-completion-packets.md");
std::fs::write(
&task_path,
"---\nid: 27\ntitle: Completion packets\nstatus: review\npriority: medium\nclaimed_by: human\nclass: standard\n---\n\nTask body.\n",
)
.unwrap();
send_message(
tmp.path(),
"architect",
r#"Done.
## Completion Packet
```json
{"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"}
```"#,
)
.unwrap();
let metadata = board::read_workflow_metadata(&task_path).unwrap();
assert!(metadata.branch.is_none());
assert!(metadata.tests_run.is_none());
assert!(metadata.review_blockers.is_empty());
}
#[test]
fn assign_task_delivers_to_inbox() {
let tmp = tempfile::tempdir().unwrap();
let _tmux_pane = EnvVarGuard::unset("TMUX_PANE");
let _batty_member = EnvVarGuard::unset("BATTY_MEMBER");
let id = assign_task(tmp.path(), "eng-1-1", "fix bug").unwrap();
assert!(!id.is_empty());
let root = inbox::inboxes_root(tmp.path());
let pending = inbox::pending_messages(&root, "eng-1-1").unwrap();
assert_eq!(pending.len(), 1);
let expected_from = effective_sender(tmp.path(), None);
assert_eq!(pending[0].from, expected_from);
assert_eq!(pending[0].to, "eng-1-1");
assert_eq!(pending[0].body, "fix bug");
assert_eq!(pending[0].msg_type, inbox::MessageType::Assign);
}
#[test]
fn assign_task_rejects_when_engineer_already_has_active_task() {
let tmp = tempfile::tempdir().unwrap();
let _tmux_pane = EnvVarGuard::unset("TMUX_PANE");
let _batty_member = EnvVarGuard::unset("BATTY_MEMBER");
let tasks_dir = board_dir_for(tmp.path()).join("tasks");
std::fs::create_dir_all(&tasks_dir).unwrap();
std::fs::write(
tasks_dir.join("042-active.md"),
"---\nid: 42\ntitle: Active work\nstatus: in-progress\nclaimed_by: eng-1-1\npriority: high\nclass: standard\n---\n\nbody\n",
).unwrap();
let err = assign_task(tmp.path(), "eng-1-1", "new work").unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("WIP limit"),
"error should mention WIP limit, got: {msg}"
);
assert!(
msg.contains("#42"),
"error should identify the active task, got: {msg}"
);
let root = inbox::inboxes_root(tmp.path());
let pending = inbox::pending_messages(&root, "eng-1-1").unwrap();
assert!(
pending.is_empty(),
"assignment must not be delivered to inbox when bailed"
);
}
#[test]
fn assign_task_allows_when_engineers_only_active_is_done_or_todo() {
let tmp = tempfile::tempdir().unwrap();
let _tmux_pane = EnvVarGuard::unset("TMUX_PANE");
let _batty_member = EnvVarGuard::unset("BATTY_MEMBER");
let tasks_dir = board_dir_for(tmp.path()).join("tasks");
std::fs::create_dir_all(&tasks_dir).unwrap();
std::fs::write(
tasks_dir.join("042-old.md"),
"---\nid: 42\ntitle: Old completed\nstatus: done\nclaimed_by: eng-1-1\npriority: high\nclass: standard\n---\n\nbody\n",
).unwrap();
let id = assign_task(tmp.path(), "eng-1-1", "new work").unwrap();
assert!(!id.is_empty());
}
#[test]
#[serial]
fn send_message_ignores_detected_sender_outside_project() {
let tmp = tempfile::tempdir().unwrap();
let _member = EnvVarGuard::unset("TMUX_PANE");
let original_member = std::env::var("BATTY_MEMBER").ok();
unsafe {
std::env::set_var("BATTY_MEMBER", "foreign-engineer-9-9");
}
send_message(tmp.path(), "architect", "hello").unwrap();
match original_member.as_deref() {
Some(value) => unsafe {
std::env::set_var("BATTY_MEMBER", value);
},
None => unsafe {
std::env::remove_var("BATTY_MEMBER");
},
}
let root = inbox::inboxes_root(tmp.path());
let pending = inbox::pending_messages(&root, "architect").unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].from, "human");
}
#[test]
fn resolve_member_name_maps_unique_role_alias_to_instance() {
let tmp = tempfile::tempdir().unwrap();
write_team_config(
tmp.path(),
r#"
name: test
roles:
- name: human
role_type: user
talks_to:
- sam-designer
- name: jordan-pm
role_type: manager
agent: claude
instances: 1
- name: sam-designer
role_type: engineer
agent: codex
instances: 1
talks_to:
- jordan-pm
"#,
);
assert_eq!(
resolve_member_name(tmp.path(), "sam-designer").unwrap(),
"sam-designer-1-1"
);
assert_eq!(
resolve_member_name(tmp.path(), "sam-designer-1-1").unwrap(),
"sam-designer-1-1"
);
}
#[test]
fn resolve_member_name_rejects_ambiguous_role_alias() {
let tmp = tempfile::tempdir().unwrap();
write_team_config(
tmp.path(),
r#"
name: test
roles:
- name: jordan-pm
role_type: manager
agent: claude
instances: 2
- name: sam-designer
role_type: engineer
agent: codex
instances: 1
talks_to:
- jordan-pm
"#,
);
let error = resolve_member_name(tmp.path(), "sam-designer")
.unwrap_err()
.to_string();
assert!(error.contains("matches multiple members"));
assert!(error.contains("sam-designer-1-1"));
assert!(error.contains("sam-designer-2-1"));
}
#[test]
#[serial]
fn send_message_delivers_to_unique_instance_inbox() {
let tmp = tempfile::tempdir().unwrap();
let _tmux_pane = EnvVarGuard::unset("TMUX_PANE");
let _batty_member = EnvVarGuard::unset("BATTY_MEMBER");
write_team_config(
tmp.path(),
r#"
name: test
roles:
- name: human
role_type: user
talks_to:
- sam-designer
- name: jordan-pm
role_type: manager
agent: claude
instances: 1
- name: sam-designer
role_type: engineer
agent: codex
instances: 1
talks_to:
- jordan-pm
"#,
);
let original_tmux_pane = std::env::var_os("TMUX_PANE");
unsafe {
std::env::remove_var("TMUX_PANE");
}
let send_result = send_message(tmp.path(), "sam-designer", "hello");
match original_tmux_pane {
Some(value) => unsafe {
std::env::set_var("TMUX_PANE", value);
},
None => unsafe {
std::env::remove_var("TMUX_PANE");
},
}
send_result.unwrap();
let root = inbox::inboxes_root(tmp.path());
assert!(
inbox::pending_messages(&root, "sam-designer")
.unwrap()
.is_empty()
);
let pending = inbox::pending_messages(&root, "sam-designer-1-1").unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].to, "sam-designer-1-1");
assert_eq!(pending[0].body, "hello");
}
#[test]
fn truncate_chars_handles_unicode_boundaries() {
let body = "Task #109 confirmed complete on main. I'm available for next assignment.";
let truncated = truncate_chars(body, 40);
assert!(truncated.ends_with("..."));
assert!(truncated.starts_with("Task #109 confirmed complete on main."));
}
#[test]
fn format_inbox_listing_shows_most_recent_messages_by_default_limit() {
let messages: Vec<_> = (0..25)
.map(|idx| {
(
inbox::InboxMessage {
id: format!("msg{idx:05}"),
from: "architect".to_string(),
to: "black-lead".to_string(),
body: format!("message {idx}"),
msg_type: inbox::MessageType::Send,
timestamp: idx,
},
true,
)
})
.collect();
let rendered = format_inbox_listing("black-lead", &messages, Some(20));
assert!(rendered.contains("Showing 20 of 25 messages for black-lead."));
assert!(!rendered.contains("message 0"));
assert!(rendered.contains("message 5"));
assert!(rendered.contains("message 24"));
assert!(!rendered.contains("msg00005"));
assert!(!rendered.contains("msg00024"));
}
#[test]
fn format_inbox_listing_allows_showing_all_messages() {
let messages: Vec<_> = (0..3)
.map(|idx| {
(
inbox::InboxMessage {
id: format!("msg{idx:05}"),
from: "architect".to_string(),
to: "black-lead".to_string(),
body: format!("message {idx}"),
msg_type: inbox::MessageType::Send,
timestamp: idx,
},
idx % 2 == 0,
)
})
.collect();
let rendered = format_inbox_listing("black-lead", &messages, None);
assert!(!rendered.contains("Showing 20"));
assert!(rendered.contains("REF"));
assert!(rendered.contains("BODY"));
assert!(rendered.contains("message 0"));
assert!(rendered.contains("message 1"));
assert!(rendered.contains("message 2"));
assert!(!rendered.contains("msg00000"));
assert!(!rendered.contains("msg00001"));
assert!(!rendered.contains("msg00002"));
}
#[test]
fn format_inbox_listing_hides_internal_message_ids() {
let messages = vec![(
inbox::InboxMessage {
id: "1773930387654321.M123456P7890Q42.example".to_string(),
from: "architect".to_string(),
to: "black-lead".to_string(),
body: "message body".to_string(),
msg_type: inbox::MessageType::Send,
timestamp: 1_773_930_725,
},
true,
)];
let rendered = format_inbox_listing("black-lead", &messages, None);
assert!(rendered.contains("1773930725"));
assert!(!rendered.contains("1773930387654321.M123456P7890Q42.example"));
assert!(!rendered.contains("ID BODY"));
}
#[test]
fn inbox_message_refs_use_timestamp_when_unique() {
let messages = vec![(
inbox::InboxMessage {
id: "msg-1".to_string(),
from: "architect".to_string(),
to: "black-lead".to_string(),
body: "message body".to_string(),
msg_type: inbox::MessageType::Send,
timestamp: 1_773_930_725,
},
true,
)];
let refs = inbox_message_refs(&messages);
assert_eq!(refs, vec!["1773930725".to_string()]);
assert_eq!(
resolve_inbox_message_indices(&messages, "1773930725"),
vec![0]
);
}
#[test]
fn inbox_message_refs_suffix_same_second_collisions() {
let messages = vec![
(
inbox::InboxMessage {
id: "msg-1".to_string(),
from: "architect".to_string(),
to: "black-lead".to_string(),
body: "first".to_string(),
msg_type: inbox::MessageType::Send,
timestamp: 1_773_930_725,
},
true,
),
(
inbox::InboxMessage {
id: "msg-2".to_string(),
from: "architect".to_string(),
to: "black-lead".to_string(),
body: "second".to_string(),
msg_type: inbox::MessageType::Send,
timestamp: 1_773_930_725,
},
true,
),
];
let refs = inbox_message_refs(&messages);
assert_eq!(
refs,
vec!["1773930725-1".to_string(), "1773930725-2".to_string()]
);
assert!(resolve_inbox_message_indices(&messages, "1773930725").is_empty());
assert_eq!(
resolve_inbox_message_indices(&messages, "1773930725-1"),
vec![0]
);
assert_eq!(
resolve_inbox_message_indices(&messages, "1773930725-2"),
vec![1]
);
}
#[test]
fn format_inbox_digest_empty_inbox() {
let rendered = format_inbox_digest("manager", &[], None, None);
assert_eq!(rendered, "No messages for manager.\n");
}
#[test]
fn format_inbox_digest_shows_category_column() {
let messages = vec![(
inbox::InboxMessage {
id: "msg1".to_string(),
from: "eng-1".to_string(),
to: "manager".to_string(),
body: "Task #42 escalated: critical".to_string(),
msg_type: inbox::MessageType::Send,
timestamp: 100,
},
false,
)];
let rendered = format_inbox_digest("manager", &messages, None, None);
assert!(rendered.contains("CATEGORY"));
assert!(rendered.contains("ESCALATION"));
}
#[test]
fn format_inbox_digest_shows_collapsed_count() {
let messages: Vec<_> = (0..3)
.map(|i| {
(
inbox::InboxMessage {
id: format!("msg{i}"),
from: "daemon".to_string(),
to: "eng-1".to_string(),
body: "Idle nudge: move forward".to_string(),
msg_type: inbox::MessageType::Send,
timestamp: 100 + i as u64,
},
true,
)
})
.collect();
let rendered = format_inbox_digest("eng-1", &messages, None, None);
assert!(rendered.contains("x3"), "should show collapsed count x3");
assert!(rendered.contains("nudge"));
}
#[test]
fn format_inbox_digest_shows_compression_header() {
let messages: Vec<_> = (0..5)
.map(|i| {
(
inbox::InboxMessage {
id: format!("msg{i}"),
from: "daemon".to_string(),
to: "eng-1".to_string(),
body: "Idle nudge: move forward".to_string(),
msg_type: inbox::MessageType::Send,
timestamp: 100 + i as u64,
},
true,
)
})
.collect();
let rendered = format_inbox_digest("eng-1", &messages, None, None);
assert!(rendered.contains("Digest: 1 entries from 5 messages"));
assert!(rendered.contains("4 collapsed"));
assert!(rendered.contains("--raw"));
}
#[test]
fn format_inbox_digest_respects_limit() {
let messages: Vec<_> = vec![
(
inbox::InboxMessage {
id: "msg1".to_string(),
from: "eng-1".to_string(),
to: "manager".to_string(),
body: "Task #42 escalated: critical".to_string(),
msg_type: inbox::MessageType::Send,
timestamp: 100,
},
false,
),
(
inbox::InboxMessage {
id: "msg2".to_string(),
from: "eng-1".to_string(),
to: "manager".to_string(),
body: "Task #42 ready for review".to_string(),
msg_type: inbox::MessageType::Send,
timestamp: 200,
},
true,
),
(
inbox::InboxMessage {
id: "msg3".to_string(),
from: "daemon".to_string(),
to: "manager".to_string(),
body: "Idle nudge: move forward".to_string(),
msg_type: inbox::MessageType::Send,
timestamp: 300,
},
true,
),
];
let rendered = format_inbox_digest("manager", &messages, Some(2), None);
assert!(rendered.contains("Showing 2 of 3 entries"));
assert!(rendered.contains("ESCALATION"));
assert!(rendered.contains("REVIEW"));
assert!(!rendered.contains("nudge"));
}
#[test]
fn format_inbox_digest_prioritizes_manual_review_notice_over_status_when_limited() {
let messages: Vec<_> = vec![
(
inbox::InboxMessage {
id: "msg1".to_string(),
from: "architect".to_string(),
to: "manager".to_string(),
body: "Status update: triage queue is unchanged.".to_string(),
msg_type: inbox::MessageType::Send,
timestamp: 100,
},
true,
),
(
inbox::InboxMessage {
id: "msg2".to_string(),
from: "eng-1".to_string(),
to: "manager".to_string(),
body: "[eng-1] Task #42 passed tests but requires manual review.\nTitle: Inbox routing"
.to_string(),
msg_type: inbox::MessageType::Send,
timestamp: 200,
},
true,
),
];
let rendered = format_inbox_digest("manager", &messages, Some(1), None);
assert!(rendered.contains("REVIEW"));
assert!(rendered.contains("requires manual review"));
assert!(!rendered.contains("Status update: triage queue is unchanged."));
}
#[test]
fn format_inbox_digest_prioritizes_action_labels_over_low_signal_chatter() {
let messages: Vec<_> = vec![
(
inbox::InboxMessage {
id: "msg1".to_string(),
from: "daemon".to_string(),
to: "manager".to_string(),
body: "COMMIT REMINDER: You have uncommitted work.".to_string(),
msg_type: inbox::MessageType::Send,
timestamp: 100,
},
true,
),
(
inbox::InboxMessage {
id: "msg2".to_string(),
from: "manager".to_string(),
to: "eng-1".to_string(),
body: "REVIEW PASSED #696: merged to main and moved to done.".to_string(),
msg_type: inbox::MessageType::Send,
timestamp: 200,
},
true,
),
(
inbox::InboxMessage {
id: "msg3".to_string(),
from: "eng-1".to_string(),
to: "manager".to_string(),
body: r#"{"task_id":708,"branch":"eng-1-3/708","tests_passed":true,"outcome":"ready_for_review"}"#.to_string(),
msg_type: inbox::MessageType::Send,
timestamp: 300,
},
false,
),
(
inbox::InboxMessage {
id: "msg4".to_string(),
from: "daemon".to_string(),
to: "manager".to_string(),
body: "Dispatch recovery needed: idle engineer(s), top task #708.".to_string(),
msg_type: inbox::MessageType::Send,
timestamp: 400,
},
false,
),
(
inbox::InboxMessage {
id: "msg5".to_string(),
from: "eng-2".to_string(),
to: "manager".to_string(),
body: "Task #709 ready for review".to_string(),
msg_type: inbox::MessageType::Send,
timestamp: 500,
},
false,
),
];
let rendered = format_inbox_digest("manager", &messages, Some(3), None);
assert!(rendered.contains("REVIEW"));
assert!(rendered.contains("DISPATCH"));
assert!(rendered.contains("COMPLETION"));
assert!(!rendered.contains("COMMIT REMINDER"));
assert!(!rendered.contains("REVIEW PASSED"));
let raw = format_inbox_listing("manager", &messages, None);
assert!(raw.contains("COMMIT REMINDER"));
assert!(raw.contains("REVIEW PASSED"));
}
}