use std::io::Write;
use std::process::{Command, Stdio};
use tempfile::TempDir;
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_agent-status")
}
fn run(state_dir: &std::path::Path, args: &[&str], stdin: Option<&str>) -> (String, String, i32) {
let mut cmd = Command::new(bin());
cmd.args(args)
.env("XDG_RUNTIME_DIR", state_dir.parent().unwrap())
.env_remove("CLAUDE_PROJECT_DIR")
.env_remove("TMUX_PANE")
.stdin(if stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("spawn binary");
if let Some(s) = stdin {
child
.stdin
.take()
.unwrap()
.write_all(s.as_bytes())
.unwrap();
}
let out = child.wait_with_output().expect("wait");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.code().unwrap_or(-1),
)
}
#[test]
fn end_to_end_set_status_clear() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("agent-status");
let (stdout, _, code) = run(&state_dir, &["status"], None);
assert_eq!(code, 0);
assert_eq!(stdout, "");
let (_, _, code) = run(
&state_dir,
&["set", "notify", "--agent", "claude-code"],
Some(r#"{"session_id":"sess-A"}"#),
);
assert_eq!(code, 0);
let (stdout, _, code) = run(&state_dir, &["status"], None);
assert_eq!(code, 0);
assert!(stdout.starts_with("[!] "), "got: {stdout:?}");
let (_, _, code) = run(
&state_dir,
&["clear", "--agent", "claude-code"],
Some(r#"{"session_id":"sess-A"}"#),
);
assert_eq!(code, 0);
let (stdout, _, code) = run(&state_dir, &["status"], None);
assert_eq!(code, 0);
assert_eq!(stdout, "");
}
#[test]
fn unknown_subcommand_exits_2() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("agent-status");
let (_, stderr, code) = run(&state_dir, &["frobnicate"], None);
assert_eq!(code, 2);
assert!(!stderr.is_empty(), "expected non-empty stderr, got: {stderr:?}");
}
#[test]
fn set_with_empty_session_id_is_noop() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("agent-status");
let (_, _, code) = run(
&state_dir,
&["set", "notify", "--agent", "claude-code"],
Some(r#"{"session_id":""}"#),
);
assert_eq!(code, 0);
let (stdout, _, _) = run(&state_dir, &["status"], None);
assert_eq!(stdout, "");
}
#[test]
fn list_outputs_session_id_pane_display_columns() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("agent-status");
let (_, _, code) = run(
&state_dir,
&["set", "notify", "--agent", "claude-code"],
Some(r#"{"session_id":"sess-list","message":"Permission required"}"#),
);
assert_eq!(code, 0);
let (stdout, _, code) = run(&state_dir, &["list"], None);
assert_eq!(code, 0);
let line = stdout.lines().next().expect("at least one line");
let cols: Vec<&str> = line.split('\t').collect();
assert_eq!(cols.len(), 3, "expected 3 columns, got: {cols:?}");
assert_eq!(cols[0], "sess-list");
assert_eq!(cols[1], "");
assert!(cols[2].starts_with("[!] "), "got: {:?}", cols[2]);
assert!(!cols[2].contains("notify"), "event word leaked: {:?}", cols[2]);
assert!(cols[2].contains("Permission required"));
}
#[test]
fn status_prunes_state_file_with_dead_pid() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("agent-status");
std::fs::create_dir_all(&state_dir).unwrap();
let json = r#"{"agent":"claude-code","project":"ghost","cwd":"/x","event":"notify","tmux_pane":"","ts":1,"pid":1000000000}"#;
std::fs::write(state_dir.join("ghost-session"), json).unwrap();
assert!(state_dir.join("ghost-session").exists());
let (stdout, _, code) = run(&state_dir, &["status"], None);
assert_eq!(code, 0);
assert_eq!(stdout, "", "status should report no waiting sessions");
assert!(
!state_dir.join("ghost-session").exists(),
"stale state file should have been pruned by the status read",
);
}
#[test]
fn repeated_clear_is_idempotent_and_silent() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("agent-status");
let (stdout, stderr, code) = run(
&state_dir,
&["clear", "--agent", "claude-code"],
Some(r#"{"session_id":"ghost"}"#),
);
assert_eq!(code, 0, "stderr: {stderr}");
assert_eq!(stdout, "");
let (stdout, _, code) = run(
&state_dir,
&["clear", "--agent", "claude-code"],
Some(r#"{"session_id":"ghost"}"#),
);
assert_eq!(code, 0);
assert_eq!(stdout, "", "second no-op clear must stay silent");
let (_, _, code) = run(
&state_dir,
&["set", "notify", "--agent", "claude-code"],
Some(r#"{"session_id":"s"}"#),
);
assert_eq!(code, 0);
let (stdout, _, code) = run(
&state_dir,
&["clear", "--agent", "claude-code"],
Some(r#"{"session_id":"s"}"#),
);
assert_eq!(code, 0);
assert_eq!(stdout, "", "clear of a previously-set session must stay silent");
let (stdout, _, code) = run(
&state_dir,
&["clear", "--agent", "claude-code"],
Some(r#"{"session_id":"s"}"#),
);
assert_eq!(code, 0);
assert_eq!(stdout, "", "follow-up clear of cleared session must stay silent");
}
#[test]
fn status_keeps_state_file_with_live_pid() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("agent-status");
std::fs::create_dir_all(&state_dir).unwrap();
let live_pid = std::process::id();
let json = format!(
r#"{{"agent":"claude-code","project":"alive","cwd":"/x","event":"notify","tmux_pane":"","ts":1,"pid":{live_pid}}}"#
);
std::fs::write(state_dir.join("alive-session"), json).unwrap();
let (stdout, _, code) = run(&state_dir, &["status"], None);
assert_eq!(code, 0);
assert!(stdout.starts_with("[!] "), "live entry should appear in status, got: {stdout:?}");
assert!(
state_dir.join("alive-session").exists(),
"live state file must not be pruned",
);
}
#[test]
fn agent_extension_writes_file_and_prints_path() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("agent-status");
let (stdout, stderr, code) = run(
&state_dir,
&["agent-extension", "--agent", "claude-code"],
None,
);
assert_eq!(code, 0, "stderr: {stderr}");
let printed_path = stdout.trim_end_matches('\n');
let expected = state_dir.join("extensions").join("claude-code.json");
assert_eq!(printed_path, expected.to_string_lossy());
let contents = std::fs::read_to_string(&expected).expect("settings file written");
let parsed: serde_json::Value = serde_json::from_str(&contents).expect("valid json");
let hooks = parsed.get("hooks").expect("hooks key present");
for event in [
"Notification",
"Stop",
"UserPromptSubmit",
"PreToolUse",
"SessionStart",
"SessionEnd",
] {
assert!(hooks.get(event).is_some(), "missing hook event {event}");
}
}
#[test]
fn agent_extension_unknown_agent_exits_nonzero() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("agent-status");
let (_, stderr, code) = run(&state_dir, &["agent-extension", "--agent", "frobnicator"], None);
assert_eq!(code, 2, "clap parse error should exit 2");
assert!(
stderr.contains("invalid value 'frobnicator'") || stderr.contains("possible values"),
"stderr: {stderr:?}",
);
}
#[test]
fn agent_extension_pi_coding_agent_writes_ts_file() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("agent-status");
let (stdout, stderr, code) = run(
&state_dir,
&["agent-extension", "--agent", "pi-coding-agent"],
None,
);
assert_eq!(code, 0, "stderr: {stderr}");
let printed_path = stdout.trim_end_matches('\n');
let expected = state_dir.join("extensions").join("pi-coding-agent.ts");
assert_eq!(printed_path, expected.to_string_lossy());
let contents = std::fs::read_to_string(&expected).expect("extension file written");
assert!(
contents.contains(r#"const BIN = ""#),
"expected substituted BIN, got:\n{contents}",
);
assert!(
!contents.contains("process.env.AGENT_STATUS_BIN ??"),
"env-fallback should have been replaced",
);
assert!(contents.contains("export default function"));
assert!(contents.contains("pi.on(\"agent_end\""));
}
#[test]
fn agent_extension_opencode_writes_ts_file() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("agent-status");
let (stdout, stderr, code) = run(
&state_dir,
&["agent-extension", "--agent", "opencode"],
None,
);
assert_eq!(code, 0, "stderr: {stderr}");
let printed_path = stdout.trim_end_matches('\n');
let expected = state_dir.join("extensions").join("opencode.ts");
assert_eq!(printed_path, expected.to_string_lossy());
let contents = std::fs::read_to_string(&expected).expect("extension file written");
assert!(
contents.contains(r#"const BIN = ""#),
"expected substituted BIN, got:\n{contents}",
);
assert!(
!contents.contains("process.env.AGENT_STATUS_BIN ??"),
"env-fallback should have been replaced",
);
assert!(contents.contains("AgentStatusPlugin"));
}
#[test]
fn working_status_is_recorded_but_hidden_from_indicator_and_list() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("agent-status");
let (_, _, code) = run(
&state_dir,
&["set", "working", "--agent", "claude-code"],
Some(r#"{"session_id":"sess-work"}"#),
);
assert_eq!(code, 0);
assert!(state_dir.join("sess-work").exists());
let (stdout, _, code) = run(&state_dir, &["status"], None);
assert_eq!(code, 0);
assert_eq!(stdout, "");
let (stdout, _, code) = run(&state_dir, &["list"], None);
assert_eq!(code, 0);
assert_eq!(stdout, "");
let (_, _, code) = run(
&state_dir,
&["set", "notify", "--agent", "claude-code"],
Some(r#"{"session_id":"sess-wait"}"#),
);
assert_eq!(code, 0);
let (stdout, _, _) = run(&state_dir, &["status"], None);
assert!(stdout.starts_with("[!] "), "got: {stdout:?}");
let (stdout, _, _) = run(&state_dir, &["list"], None);
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines.len(), 1, "got: {lines:?}");
assert!(lines[0].contains("sess-wait"));
}
#[test]
fn working_entry_with_pre_tool_use_payload_records_activity_message() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("agent-status");
let payload = r#"{
"session_id": "sess-work",
"transcript_path": "/x/y.jsonl",
"tool_name": "Read",
"tool_input": {"file_path": "/repo/src/lib.rs"}
}"#;
let (_, stderr, code) = run(
&state_dir,
&["set", "working", "--agent", "claude-code"],
Some(payload),
);
assert_eq!(code, 0, "stderr: {stderr}");
let state_file = state_dir.join("sess-work");
assert!(state_file.exists(), "expected state file at {state_file:?}");
let raw = std::fs::read_to_string(&state_file).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(parsed["event"], "working");
assert_eq!(
parsed["message"].as_str(),
Some("Reading src/lib.rs"),
"expected derived activity in message; got: {raw}",
);
let (stdout, _, _) = run(&state_dir, &["status"], None);
assert_eq!(stdout, "", "working must not appear in tmux status");
let (stdout, _, _) = run(&state_dir, &["list"], None);
assert_eq!(stdout, "", "working must not appear in switcher list");
}