use std::future::IntoFuture as _;
use super::super::managed_render::{
format_ls_row, format_state_column, format_tombstone_row, short_timestamp, state_column_width,
truncate,
};
use super::{session_activity, session_decommission, session_resume, session_stop};
#[test]
fn truncate_clips_and_appends_ellipsis() {
assert_eq!(truncate("hello", 10), "hello");
assert_eq!(truncate("hello world", 5), "hell\u{2026}");
assert_eq!(truncate("", 5), "");
assert_eq!(truncate("abcde", 5), "abcde");
}
#[test]
fn format_state_column_appends_dead_marker() {
assert_eq!(
format_state_column("stopped", true, false, false),
"stopped [dead]"
);
assert_eq!(
format_state_column("errored", true, false, false),
"errored [dead]"
);
}
#[test]
fn format_state_column_appends_stale_assets_marker() {
assert_eq!(
format_state_column("active", false, true, false),
"active [stale-assets]"
);
assert_eq!(
format_state_column("stopped", true, true, false),
"stopped [dead] [stale-assets]",
"both markers must be able to appear together"
);
}
#[test]
fn format_state_column_appends_unchecked_assets_marker() {
assert_eq!(
format_state_column("stopped", false, false, true),
"stopped [assets ?]"
);
assert_eq!(
format_state_column("stopped", true, false, true),
"stopped [dead] [assets ?]",
"the dead marker and the undetermined-assets marker must compose"
);
assert_eq!(
format_state_column("stopped", false, true, true),
"stopped [stale-assets]",
"a real stale verdict must win over 'undetermined' — never both"
);
}
#[test]
fn format_state_column_leaves_healthy_state_unchanged() {
assert_eq!(format_state_column("active", false, false, false), "active");
assert_eq!(
format_state_column("stopped", false, false, false),
"stopped"
);
assert_eq!(
format_state_column("provisioning", false, false, false),
"provisioning"
);
}
#[test]
fn format_tombstone_row_shows_slot_and_placeholder() {
assert_eq!(format_tombstone_row(1, false), "1 -- deleted --");
assert_eq!(format_tombstone_row(42, false), "42 -- deleted --");
}
#[test]
fn format_state_column_renders_deleted_marker() {
assert_eq!(
format_state_column("deleted", false, false, false),
"--deleted--"
);
assert_eq!(
format_state_column("deleted", true, false, false),
"--deleted-- [dead]"
);
}
#[test]
fn short_timestamp_formats_correctly() {
assert_eq!(short_timestamp("2025-06-27T14:32:00Z"), "2025-06-27 14:32");
assert_eq!(short_timestamp("short"), "short");
assert_eq!(short_timestamp("2025-06-27T14:32"), "2025-06-27 14:32");
}
#[test]
fn decommission_message_reflects_workspace_removed() {
let owned_removed = serde_json::json!({
"id": "abc-123",
"workspace_removed": true,
"workspace_path_was": "/some/workspace/path"
});
assert_eq!(
owned_removed
.get("workspace_removed")
.and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
owned_removed
.get("workspace_path_was")
.and_then(|v| v.as_str()),
Some("/some/workspace/path")
);
let adopted_not_removed = serde_json::json!({
"id": "xyz-456",
"workspace_removed": false
});
assert_eq!(
adopted_not_removed
.get("workspace_removed")
.and_then(|v| v.as_bool()),
Some(false)
);
assert!(adopted_not_removed.get("workspace_path_was").is_none());
}
async fn spawn_test_daemon() -> String {
use trusty_mpm::daemon::{api, state::DaemonState};
let root = tempfile::tempdir().unwrap().keep();
let state = std::sync::Arc::new(DaemonState::with_root_isolated_managed(root).await);
let router = api::router(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(axum::serve(listener, router).into_future());
format!("http://{addr}")
}
#[tokio::test]
async fn session_stop_not_found_errors() {
let url = spawn_test_daemon().await;
let client = reqwest::Client::new();
let err = session_stop(&client, &url, "nonexistent-id".to_string())
.await
.expect_err("a missing managed session must be a hard failure, not a silent Ok(())");
assert!(
err.to_string().contains("nonexistent-id"),
"error should name the missing id: {err}"
);
}
#[tokio::test]
async fn session_resume_not_found_errors() {
let url = spawn_test_daemon().await;
let client = reqwest::Client::new();
let err = session_resume(&client, &url, "nonexistent-id".to_string())
.await
.expect_err("a missing managed session must be a hard failure, not a silent Ok(())");
assert!(
err.to_string().contains("nonexistent-id"),
"error should name the missing id: {err}"
);
}
async fn spawn_test_daemon_with_unrestartable_stopped_session() -> (String, String) {
use trusty_mpm::daemon::{api, state::DaemonState};
use trusty_mpm::runtime::RuntimeKind;
use trusty_mpm::session_manager::ManagedSessionId;
let root = tempfile::tempdir().unwrap().keep();
let state = std::sync::Arc::new(DaemonState::with_root_isolated_managed(root.clone()).await);
let id = ManagedSessionId::new();
let ws = root.join(format!("{id}-resume-missing-ws"));
let mgr = state.session_manager().await;
mgr.create_with_id(
id,
"regression: #2649 resume-restart-failure CLI test".to_string(),
Some(ws.clone()),
None,
Some(ws.clone()),
Some("https://example.com/r.git".to_string()),
Some("main".to_string()),
RuntimeKind::default(),
false,
false,
)
.await
.expect("seed session");
mgr.stop(&id).await.expect("mark seeded session Stopped");
assert!(
!ws.exists(),
"#3715: stop()'s snapshot capture must NOT recreate a vanished workspace root"
);
let router = api::router(std::sync::Arc::clone(&state));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(axum::serve(listener, router).into_future());
(format!("http://{addr}"), id.to_string())
}
#[tokio::test]
async fn session_resume_restart_failure_errors() {
let (url, id) = spawn_test_daemon_with_unrestartable_stopped_session().await;
let client = reqwest::Client::new();
let err = session_resume(&client, &url, id)
.await
.expect_err("a daemon-rejected restart must be a hard failure, not Ok(())");
assert!(
err.to_string().contains("cannot restart"),
"error should surface the daemon's rejection: {err}"
);
}
#[tokio::test]
async fn session_resume_headless_active_live_tmux_skips_restart_and_attach() {
use trusty_mpm::daemon::{api, state::DaemonState};
use trusty_mpm::runtime::RuntimeKind;
use trusty_mpm::session_manager::ManagedSessionId;
let root = tempfile::tempdir().unwrap().keep();
let state = std::sync::Arc::new(DaemonState::with_root_isolated_managed(root.clone()).await);
let id = ManagedSessionId::new();
let ws = root.join(format!("{id}-headless-attach-ws"));
let mgr = state.session_manager().await;
let record = mgr
.create_with_id(
id,
"regression: #2649 headless active-live-tmux CLI test".to_string(),
Some(ws.clone()),
None,
Some(ws),
Some(format!("https://example.com/r{}.git", std::process::id())),
Some("main".to_string()),
RuntimeKind::default(),
false,
false,
)
.await
.expect("seed session");
assert_eq!(
record.state.to_string(),
"provisioning",
"sanity: create_with_id must leave a fresh record un-stopped/un-errored"
);
let tmux_bin = trusty_mpm::core::tmux::resolve_tmux_binary_or_bare();
let create_status = std::process::Command::new(&tmux_bin)
.args(["new-session", "-d", "-s", &record.tmux_name, "sleep 300"])
.status()
.expect("spawn real tmux session for the liveness probe");
assert!(
create_status.success(),
"failed to create the real scratch tmux session"
);
wait_for_stable_live_runtime(&record.tmux_name);
let router = api::router(std::sync::Arc::clone(&state));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(axum::serve(listener, router).into_future());
let url = format!("http://{addr}");
let client = reqwest::Client::new();
let result = session_resume(&client, &url, id.to_string()).await;
let _ = std::process::Command::new(&tmux_bin)
.args(["kill-session", "-t", &record.tmux_name])
.output();
result.expect(
"headless resume of an already-live, non-stopped/errored session must succeed (Ok), \
not error",
);
let after: serde_json::Value = client
.get(format!("{url}/api/v1/sessions/managed/{id}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(
after.get("state").and_then(|v| v.as_str()),
Some("provisioning"),
"state must be UNCHANGED — a /resume POST would have flipped it to 'active'; this \
proves the Attach branch never issued a daemon-side restart"
);
}
fn wait_for_stable_live_runtime(session_name: &str) {
const REQUIRED_AGREEING_READS: u32 = 3;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut streak = 0;
while std::time::Instant::now() < deadline {
if crate::commands::guided_resume::session_runtime_live(session_name) {
streak += 1;
if streak == REQUIRED_AGREEING_READS {
return;
}
} else {
streak = 0;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
panic!(
"fixture precondition: tmux session '{session_name}' never settled into a \
stable live-runtime state within 10s; this test would silently exercise \
the #3873 reconcile branch instead of the Attach branch it asserts about"
);
}
fn wait_for_stable_dead_runtime(session_name: &str) {
const REQUIRED_AGREEING_READS: u32 = 3;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut streak = 0;
while std::time::Instant::now() < deadline {
if crate::commands::guided_resume::session_runtime_live(session_name) {
streak = 0;
} else {
streak += 1;
if streak == REQUIRED_AGREEING_READS {
return;
}
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
panic!(
"fixture precondition: tmux session '{session_name}' never settled into a \
stable dead-runtime state within 10s; this test cannot distinguish the \
#3873 reconcile branch from Attach without it"
);
}
#[tokio::test]
async fn session_resume_headless_dead_runtime_reconciles_and_restarts() {
use trusty_mpm::daemon::{api, state::DaemonState};
use trusty_mpm::runtime::RuntimeKind;
use trusty_mpm::session_manager::ManagedSessionId;
let root = tempfile::tempdir().unwrap().keep();
let state = std::sync::Arc::new(DaemonState::with_root_isolated_managed(root.clone()).await);
let id = ManagedSessionId::new();
let ws = root.join(format!("{id}-dead-runtime-ws"));
std::fs::create_dir_all(&ws).expect("create workspace dir");
let mgr = state.session_manager().await;
let record = mgr
.create_with_id(
id,
"regression: #3873 dead-runtime reconcile CLI test".to_string(),
Some(ws.clone()),
None,
Some(ws),
Some(format!(
"https://example.com/deadrt{}.git",
std::process::id()
)),
Some("main".to_string()),
RuntimeKind::default(),
false,
false,
)
.await
.expect("seed session");
assert_eq!(
record.state.to_string(),
"provisioning",
"sanity: create_with_id must leave a fresh record un-stopped/un-errored"
);
let tmux_bin = trusty_mpm::core::tmux::resolve_tmux_binary_or_bare();
let create_status = std::process::Command::new(&tmux_bin)
.args(["new-session", "-d", "-s", &record.tmux_name, "sh"])
.status()
.expect("spawn real tmux session for the liveness probe");
assert!(
create_status.success(),
"failed to create the real scratch tmux session"
);
wait_for_stable_dead_runtime(&record.tmux_name);
let router = api::router(std::sync::Arc::clone(&state));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(axum::serve(listener, router).into_future());
let url = format!("http://{addr}");
let client = reqwest::Client::new();
let result = session_resume(&client, &url, id.to_string()).await;
let _ = std::process::Command::new(&tmux_bin)
.args(["kill-session", "-t", &record.tmux_name])
.output();
let _ = result;
let after: serde_json::Value = client
.get(format!("{url}/api/v1/sessions/managed/{id}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let persisted = after
.get("persisted_state")
.and_then(|v| v.as_str())
.expect("daemon must report persisted_state");
assert_ne!(
persisted, "provisioning",
"the record must have been reconciled and restarted — a /runtime-stop + \
/resume round-trip moves it off 'provisioning' (to 'active', or \
'errored' where no runtime binary exists). Reading 'provisioning' means \
the CLI attached into the idle shell instead, i.e. the #3873 defect is back"
);
}
#[tokio::test]
async fn session_resume_zombie_active_tmux_absent_reconciles_and_restarts() {
use trusty_mpm::daemon::{api, state::DaemonState};
use trusty_mpm::runtime::RuntimeKind;
use trusty_mpm::session_manager::{ManagedSessionId, ManagedSessionState};
let root = tempfile::tempdir().unwrap().keep();
let state = std::sync::Arc::new(DaemonState::with_root_isolated_managed(root.clone()).await);
let id = ManagedSessionId::new();
let ws = root.join(format!("{id}-zombie-ws"));
tokio::fs::create_dir_all(&ws)
.await
.expect("create a REAL workspace dir so the reconcile/restart attempt is not blocked");
let mgr = state.session_manager().await;
mgr.create_with_id(
id,
"regression: #3531 zombie-restart CLI test".to_string(),
Some(ws.clone()),
None,
Some(ws.clone()),
Some("https://example.com/r.git".to_string()),
Some("main".to_string()),
RuntimeKind::default(),
false,
false,
)
.await
.expect("seed session");
mgr.set_workspace(&id, ws, ManagedSessionState::Active)
.await
.expect("force record to Active (simulating a zombie)");
let router = api::router(std::sync::Arc::clone(&state));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(axum::serve(listener, router).into_future());
let url = format!("http://{addr}");
let client = reqwest::Client::new();
let before: serde_json::Value = client
.get(format!("{url}/api/v1/sessions/managed/{id}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(
before.get("state").and_then(|v| v.as_str()),
Some("stopped"),
"sanity: the list/get endpoint's display reconciliation must show \
'stopped' for an Active record with no live tmux (the #3302 behavior)"
);
assert_eq!(
before.get("persisted_state").and_then(|v| v.as_str()),
Some("active"),
"sanity: persisted_state must still carry the TRUE record state"
);
if let Err(e) = session_resume(&client, &url, id.to_string()).await {
let msg = e.to_string();
assert!(
!msg.contains("cannot resume a session in state"),
"session_resume must never dead-end on the daemon's raw 409 for a \
zombie session — the #3531 regression: {msg}"
);
}
let after: serde_json::Value = client
.get(format!("{url}/api/v1/sessions/managed/{id}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_ne!(
after.get("persisted_state").and_then(|v| v.as_str()),
None,
"persisted_state must still be present on the wire"
);
let final_state = after.get("persisted_state").and_then(|v| v.as_str());
assert!(
matches!(final_state, Some("active") | Some("errored")),
"a genuine reconcile+restart attempt must land the record in 'active' \
(spawn succeeded) or 'errored' (spawn failed for environment reasons) \
— never left untouched; got persisted_state={final_state:?}"
);
}
#[tokio::test]
async fn session_decommission_not_found_errors() {
let url = spawn_test_daemon().await;
let client = reqwest::Client::new();
let err = session_decommission(&client, &url, "nonexistent-id".to_string())
.await
.expect_err("a missing managed session must be a hard failure, not a silent Ok(())");
assert!(
err.to_string().contains("nonexistent-id"),
"error should name the missing id: {err}"
);
}
#[tokio::test]
async fn session_activity_not_found_errors() {
let url = spawn_test_daemon().await;
let client = reqwest::Client::new();
let err = session_activity(&client, &url, "nonexistent-id".to_string())
.await
.expect_err("a missing managed session must be a hard failure, not a silent Ok(())");
assert!(
err.to_string().contains("nonexistent-id"),
"error should name the missing id: {err}"
);
}
fn ls_session(name: &str, slot: u32) -> trusty_mpm::client::ManagedSessionSummary {
trusty_mpm::client::ManagedSessionSummary {
id: "11111111-2222-3333-4444-555555555555".into(),
name: name.to_string(),
state: "active".into(),
persisted_state: None,
workspace_path: None,
repo_url: None,
branch: None,
created_at: Some("2026-08-05T12:34:56Z".into()),
last_activity_at: None,
pending_decision: None,
proposed_default: None,
source_id: None,
task: Some("do the thing".into()),
cwd: None,
claude_session_id: None,
deliverable_id: None,
pane_id: None,
injection_status: None,
unresumable: false,
stale_assets: false,
stale_assets_unchecked: false,
attached: false,
slot,
deleted: false,
}
}
fn strip_ansi(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
for c in chars.by_ref() {
if c == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
#[test]
fn ls_row_colors_num_and_name_in_distinct_hues() {
let row = format_ls_row(&ls_session("tm-trusty-tools-01", 7), true, 14);
assert!(
row.starts_with("\u{1b}[35m7\u{1b}[0m"),
"NUM colored: {row:?}"
);
assert!(
row.contains("\u{1b}[36mtm-trusty-tools-01\u{1b}[0m"),
"NAME colored: {row:?}"
);
assert_ne!(
"\u{1b}[35m", "\u{1b}[36m",
"NUM and NAME must not share a hue"
);
}
#[test]
fn ls_row_plain_when_color_disabled() {
let s = ls_session("tm-trusty-tools-01", 7);
let plain = format_ls_row(&s, false, 14);
assert!(!plain.contains('\u{1b}'), "no escapes: {plain:?}");
assert_eq!(
strip_ansi(&format_ls_row(&s, true, 14)),
plain,
"color must change bytes only inside the escapes"
);
let tomb_plain = format_tombstone_row(7, false);
assert!(!tomb_plain.contains('\u{1b}'));
assert_eq!(strip_ansi(&format_tombstone_row(7, true)), tomb_plain);
}
#[test]
fn ls_row_alignment_matches_with_and_without_color() {
for name in ["a", "tm-trusty-tools-01", &"x".repeat(60)] {
for slot in [1u32, 107] {
let s = ls_session(name, slot);
assert_eq!(
strip_ansi(&format_ls_row(&s, true, 14)).chars().count(),
format_ls_row(&s, false, 14).chars().count(),
"row width drifts for name={name:?} slot={slot}"
);
}
}
for slot in [1u32, 107] {
assert_eq!(
strip_ansi(&format_tombstone_row(slot, true))
.chars()
.count(),
format_tombstone_row(slot, false).chars().count(),
"tombstone row width drifts for slot={slot}"
);
let plain = format_tombstone_row(slot, false);
assert_eq!(
&plain[..7],
&format_ls_row(&ls_session("n", slot), false, 14)[..7],
"tombstone NUM column matches the live row's"
);
}
}
#[test]
fn ls_row_colors_id_column_dimmed() {
let s = ls_session("tm-trusty-tools-01", 7);
let row = format_ls_row(&s, true, 14);
assert!(
row.contains(&format!("\u{1b}[2m{}\u{1b}[0m", s.id)),
"ID colored dim: {row:?}"
);
let hues = ["\u{1b}[35m", "\u{1b}[2m", "\u{1b}[36m"];
for (i, a) in hues.iter().enumerate() {
for b in &hues[i + 1..] {
assert_ne!(a, b, "NUM/ID/NAME must not share a hue");
}
}
}
#[test]
fn state_column_width_absorbs_longest_annotation() {
let plain = vec![ls_session("a", 1), ls_session("b", 2)];
assert_eq!(
state_column_width(&plain),
14,
"an all-plain listing keeps the historical width"
);
let mut stale = ls_session("c", 3);
stale.attached = true;
stale.stale_assets = true;
let annotated = vec![ls_session("a", 1), stale];
assert_eq!(
state_column_width(&annotated),
"attached [stale-assets]".len()
);
let mut tomb = ls_session("d", 4);
tomb.deleted = true;
tomb.state = "a-very-long-state-string-that-is-not-rendered".into();
assert_eq!(
state_column_width(&[tomb]),
14,
"a tombstone row has no STATE cell and must not widen the column"
);
}
#[test]
fn ls_table_columns_align_when_a_row_carries_an_annotation() {
let plain = ls_session("plain-name", 1);
let mut annotated = ls_session("stale-name", 2);
annotated.attached = true;
annotated.stale_assets = true;
let sessions = vec![plain.clone(), annotated.clone()];
let width = state_column_width(&sessions);
let name_offset = |row: &str, name: &str| row.find(name).expect("name present in row");
for use_color in [false, true] {
let a = strip_ansi(&format_ls_row(&plain, use_color, width));
let b = strip_ansi(&format_ls_row(&annotated, use_color, width));
assert_eq!(
name_offset(&a, "plain-name"),
name_offset(&b, "stale-name"),
"NAME column drifts between a plain and an annotated row (use_color={use_color})"
);
}
assert!(format_ls_row(&annotated, false, width).contains("attached [stale-assets]"));
for s in [&plain, &annotated] {
assert_eq!(
strip_ansi(&format_ls_row(s, true, width)),
format_ls_row(s, false, width),
"padding must be measured on visible text, not the ANSI-wrapped string"
);
}
}