use clap::Parser;
use crate::cli::{Cli, Command, SessionAction};
#[test]
fn cli_parses_session_new() {
let cli = Cli::try_parse_from([
"trusty-mpm",
"session",
"new",
"https://github.com/owner/repo",
"--git-ref",
"feat/x",
"--task",
"do the thing",
"--name-hint",
"ticket-1",
])
.unwrap();
match cli.command.unwrap() {
Command::Session {
action:
SessionAction::New {
repo,
git_ref,
task,
name_hint,
runtime,
no_inject,
deliverable,
},
} => {
assert_eq!(repo, "https://github.com/owner/repo");
assert_eq!(git_ref, "feat/x");
assert_eq!(task, "do the thing");
assert_eq!(name_hint.as_deref(), Some("ticket-1"));
assert_eq!(runtime, trusty_mpm::runtime::RuntimeKind::ClaudeCode);
assert!(!no_inject);
assert_eq!(deliverable, None);
}
other => panic!("expected session new, got {other:?}"),
}
}
#[test]
fn cli_parses_session_new_with_deliverable() {
let cli = Cli::try_parse_from([
"trusty-mpm",
"session",
"new",
"https://github.com/owner/repo",
"--task",
"do the thing",
"--deliverable",
"11111111-1111-1111-1111-111111111111",
])
.unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::New { deliverable, .. },
} => {
assert_eq!(
deliverable.as_deref(),
Some("11111111-1111-1111-1111-111111111111")
);
}
other => panic!("expected session new, got {other:?}"),
}
}
#[test]
fn cli_parses_session_new_no_inject() {
let cli = Cli::try_parse_from([
"trusty-mpm",
"session",
"new",
"https://github.com/owner/repo",
"--task",
"do the thing",
"--no-inject",
])
.unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::New { no_inject, .. },
} => {
assert!(no_inject, "--no-inject must set the flag");
}
other => panic!("expected session new, got {other:?}"),
}
}
#[test]
fn cli_parses_session_new_with_tcode_runtime() {
let cli = Cli::try_parse_from([
"trusty-mpm",
"session",
"new",
"https://github.com/owner/repo",
"--task",
"do the thing",
"--runtime",
"tcode",
])
.unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::New { runtime, .. },
} => {
assert_eq!(runtime, trusty_mpm::runtime::RuntimeKind::Tcode);
}
other => panic!("expected session new, got {other:?}"),
}
}
#[test]
fn cli_rejects_unknown_runtime_at_parse_time() {
let err = Cli::try_parse_from([
"trusty-mpm",
"session",
"new",
"https://github.com/owner/repo",
"--task",
"do the thing",
"--runtime",
"gpt",
])
.expect_err("unknown runtime must be rejected at parse time");
let msg = err.to_string();
assert!(
msg.contains("claude-code") && msg.contains("tcode"),
"error must list the supported runtimes: {msg}"
);
}
#[test]
fn cli_parses_session_ls() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "ls", "--json"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Ls { json, .. },
} => assert!(json),
other => panic!("expected session ls, got {other:?}"),
}
}
#[test]
fn cli_parses_session_ls_source_id() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "ls", "--source-id", "myorg/myrepo"])
.unwrap();
match cli.command.unwrap() {
Command::Session {
action:
SessionAction::Ls {
source_id,
current,
json,
all,
..
},
} => {
assert_eq!(source_id, Some("myorg/myrepo".to_string()));
assert!(!current);
assert!(!json);
assert!(!all, "--all must default to false");
}
other => panic!("expected session ls, got {other:?}"),
}
}
#[test]
fn cli_parses_session_ls_current() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "ls", "--current"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action:
SessionAction::Ls {
current,
source_id,
json,
all,
..
},
} => {
assert!(current);
assert!(source_id.is_none());
assert!(!json);
assert!(!all, "--all must default to false");
}
other => panic!("expected session ls, got {other:?}"),
}
}
#[test]
fn cli_session_ls_source_id_and_current_conflict() {
let result = Cli::try_parse_from([
"trusty-mpm",
"session",
"ls",
"--current",
"--source-id",
"foo/bar",
]);
assert!(
result.is_err(),
"passing both --current and --source-id must be a parse error"
);
}
#[test]
fn cli_parses_session_ls_all() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "ls", "--all"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Ls { all, json, .. },
} => {
assert!(all, "--all flag must parse to true");
assert!(!json);
}
other => panic!("expected session ls --all, got {other:?}"),
}
}
#[test]
fn cli_parses_session_activity() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "activity", "abc"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Activity { id },
} => assert_eq!(id, "abc"),
other => panic!("expected session activity, got {other:?}"),
}
}
#[test]
fn cli_parses_session_send() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "send", "abc", "hello"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Send { id, text },
} => {
assert_eq!(id, "abc");
assert_eq!(text, "hello");
}
other => panic!("expected session send, got {other:?}"),
}
}
#[test]
fn cli_parses_session_answer() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "answer", "abc", "rebase"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Answer { id, answer },
} => {
assert_eq!(id, "abc");
assert_eq!(answer, "rebase");
}
other => panic!("expected session answer, got {other:?}"),
}
}
#[test]
fn cli_parses_session_attach() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "attach", "abc"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Attach { id },
} => assert_eq!(id, "abc"),
other => panic!("expected session attach, got {other:?}"),
}
}
#[test]
fn cli_parses_session_managed_stop() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "managed-stop", "abc"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::ManagedStop { id },
} => assert_eq!(id, "abc"),
other => panic!("expected session managed-stop, got {other:?}"),
}
}
#[test]
fn cli_parses_session_runtime_stop() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "runtime-stop", "abc"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::RuntimeStop { id },
} => assert_eq!(id, "abc"),
other => panic!("expected session runtime-stop, got {other:?}"),
}
}
#[test]
fn cli_parses_session_managed_resume() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "managed-resume", "abc"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::ManagedResume { id },
} => assert_eq!(id, "abc"),
other => panic!("expected session managed-resume, got {other:?}"),
}
}
#[test]
fn cli_parses_session_stop_verb() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "stop", "abc"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Stop { id_or_name },
} => assert_eq!(id_or_name, "abc"),
other => panic!("expected session stop, got {other:?}"),
}
}
#[test]
fn cli_parses_session_resume_verb() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "resume", "abc"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Resume { id_or_name },
} => assert_eq!(id_or_name, "abc"),
other => panic!("expected session resume, got {other:?}"),
}
}
#[test]
fn cli_parses_session_decommission() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "decommission", "abc"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Decommission { id },
} => assert_eq!(id, "abc"),
other => panic!("expected session decommission, got {other:?}"),
}
}
#[test]
fn cli_parses_session_delete() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "delete", "abc"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Delete { id, force },
} => {
assert_eq!(id, "abc");
assert!(!force, "default must be the fail-closed force=false");
}
other => panic!("expected session delete, got {other:?}"),
}
let cli2 = Cli::try_parse_from(["trusty-mpm", "session", "delete", "abc", "--force"]).unwrap();
match cli2.command.unwrap() {
Command::Session {
action: SessionAction::Delete { id, force },
} => {
assert_eq!(id, "abc");
assert!(force, "--force must set force=true");
}
other => panic!("expected session delete, got {other:?}"),
}
}
#[test]
fn cli_parses_sessions_rename_two_args() {
let cli = Cli::try_parse_from(["trusty-mpm", "sessions", "rename", "tm-old-01", "tm-new-01"])
.unwrap();
match cli.command.unwrap() {
Command::Sessions {
action: SessionAction::Rename { arg1, arg2 },
} => {
assert_eq!(arg1, "tm-old-01");
assert_eq!(arg2.as_deref(), Some("tm-new-01"));
}
other => panic!("expected sessions rename, got {other:?}"),
}
}
#[test]
fn cli_parses_sessions_rename_in_session() {
let cli = Cli::try_parse_from(["trusty-mpm", "sessions", "rename", "tm-new-name"]).unwrap();
match cli.command.unwrap() {
Command::Sessions {
action: SessionAction::Rename { arg1, arg2 },
} => {
assert_eq!(arg1, "tm-new-name");
assert_eq!(arg2, None);
}
other => panic!("expected sessions rename, got {other:?}"),
}
}
#[test]
fn cli_parses_session_prune_idle() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "prune-idle", "--dry-run", "--json"])
.unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::PruneIdle { dry_run, json },
} => {
assert!(dry_run);
assert!(json);
}
other => panic!("expected session prune-idle, got {other:?}"),
}
}
#[test]
fn cli_parses_session_prune_idle_defaults() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "prune-idle"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::PruneIdle { dry_run, json },
} => {
assert!(!dry_run);
assert!(!json);
}
other => panic!("expected session prune-idle, got {other:?}"),
}
}
#[test]
fn cli_parses_session_decommission_ephemeral() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "decommission-ephemeral"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::DecommissionEphemeral,
} => {}
other => panic!("expected session decommission-ephemeral, got {other:?}"),
}
}
#[test]
fn cli_parses_session_prune() {
let cli = Cli::try_parse_from([
"trusty-mpm",
"session",
"prune",
"--state",
"stopped",
"--dry-run",
])
.unwrap();
match cli.command.unwrap() {
Command::Session {
action:
SessionAction::Prune {
state,
dry_run,
include_active,
},
} => {
assert_eq!(state, "stopped");
assert!(dry_run);
assert!(
!include_active,
"include_active defaults to the fail-closed false"
);
}
other => panic!("expected session prune, got {other:?}"),
}
}
#[test]
fn cli_session_prune_requires_state() {
let err = Cli::try_parse_from(["trusty-mpm", "session", "prune"]);
assert!(err.is_err(), "prune without --state must fail to parse");
}
#[test]
fn cli_parses_session_prune_worktrees() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "prune-worktrees"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action:
SessionAction::PruneWorktrees {
force,
discard_dirty,
merged_prs,
},
} => {
assert!(!force, "default must be dry-run (force=false)");
assert!(!discard_dirty, "default must never discard dirty work");
assert!(
!merged_prs,
"#2919: the merged-PR pass must be off by default"
);
}
other => panic!("expected session prune-worktrees, got {other:?}"),
}
let cli2 =
Cli::try_parse_from(["trusty-mpm", "session", "prune-worktrees", "--force"]).unwrap();
match cli2.command.unwrap() {
Command::Session {
action:
SessionAction::PruneWorktrees {
force,
discard_dirty,
merged_prs,
},
} => {
assert!(force, "--force must set force=true");
assert!(
!discard_dirty,
"#4091: --force alone must NOT imply discarding uncommitted work"
);
assert!(
!merged_prs,
"#2919: --force alone must NOT imply the merged-PR reclaim pass"
);
}
other => panic!("expected session prune-worktrees, got {other:?}"),
}
}
#[test]
fn cli_prune_worktrees_discard_dirty_is_opt_in() {
let cli = Cli::try_parse_from([
"trusty-mpm",
"session",
"prune-worktrees",
"--force",
"--discard-dirty",
])
.unwrap();
match cli.command.unwrap() {
Command::Session {
action:
SessionAction::PruneWorktrees {
force,
discard_dirty,
merged_prs,
},
} => {
assert!(force);
assert!(discard_dirty, "--discard-dirty must set discard_dirty=true");
assert!(
!merged_prs,
"#2919: --discard-dirty must NOT imply the merged-PR reclaim pass"
);
}
other => panic!("expected session prune-worktrees, got {other:?}"),
}
}
#[test]
fn cli_prune_worktrees_merged_prs_is_opt_in() {
let cli = Cli::try_parse_from([
"trusty-mpm",
"session",
"prune-worktrees",
"--force",
"--merged-prs",
])
.unwrap();
match cli.command.unwrap() {
Command::Session {
action:
SessionAction::PruneWorktrees {
force,
discard_dirty,
merged_prs,
},
} => {
assert!(force);
assert!(merged_prs, "--merged-prs must set merged_prs=true");
assert!(
!discard_dirty,
"#2919: --merged-prs must NOT imply discarding uncommitted work"
);
}
other => panic!("expected session prune-worktrees, got {other:?}"),
}
}
#[test]
fn cli_parses_session_reconcile_worktrees() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "reconcile-worktrees"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::ReconcileWorktrees { json },
} => assert!(!json, "the default must be the readable report, not JSON"),
other => panic!("expected session reconcile-worktrees, got {other:?}"),
}
let as_json =
Cli::try_parse_from(["trusty-mpm", "session", "reconcile-worktrees", "--json"]).unwrap();
match as_json.command.unwrap() {
Command::Session {
action: SessionAction::ReconcileWorktrees { json },
} => assert!(json),
other => panic!("expected session reconcile-worktrees, got {other:?}"),
}
}
#[test]
fn cli_reconcile_worktrees_takes_no_destructive_flag() {
for flag in ["--force", "--discard-dirty", "--dry-run"] {
assert!(
Cli::try_parse_from(["trusty-mpm", "session", "reconcile-worktrees", flag]).is_err(),
"`reconcile-worktrees {flag}` must not parse — slice 3 writes nothing"
);
}
}
#[test]
fn cli_parses_sessions_sync_assets() {
let cli = Cli::try_parse_from(["trusty-mpm", "sessions", "sync-assets", "abc"]).unwrap();
match cli.command.unwrap() {
Command::Sessions {
action: SessionAction::SyncAssets { id, all },
} => {
assert_eq!(id.as_deref(), Some("abc"));
assert!(!all);
}
other => panic!("expected sessions sync-assets, got {other:?}"),
}
}
#[test]
fn cli_parses_sessions_sync_assets_all() {
let cli = Cli::try_parse_from(["trusty-mpm", "sessions", "sync-assets", "--all"]).unwrap();
match cli.command.unwrap() {
Command::Sessions {
action: SessionAction::SyncAssets { id, all },
} => {
assert_eq!(id, None);
assert!(all);
}
other => panic!("expected sessions sync-assets --all, got {other:?}"),
}
}
#[test]
fn cli_sessions_sync_assets_requires_id_or_all() {
let err = Cli::try_parse_from(["trusty-mpm", "sessions", "sync-assets"]);
assert!(
err.is_err(),
"sync-assets with neither an id nor --all must fail to parse"
);
}
#[test]
fn cli_sessions_sync_assets_id_and_all_conflict() {
let err = Cli::try_parse_from(["trusty-mpm", "sessions", "sync-assets", "abc", "--all"]);
assert!(
err.is_err(),
"sync-assets with both an id and --all must fail to parse"
);
}
#[test]
fn cli_parses_session_catchup() {
let cli = Cli::try_parse_from([
"trusty-mpm",
"session",
"catchup",
"--all-projects",
"--full",
])
.unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Catchup { all_projects, full },
} => {
assert!(all_projects, "--all-projects should be true");
assert!(full, "--full should be true");
}
other => panic!("expected session catchup, got {other:?}"),
}
}
#[test]
fn cli_parses_session_catchup_defaults() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "catchup"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Catchup { all_projects, full },
} => {
assert!(!all_projects, "--all-projects should default to false");
assert!(!full, "--full should default to false");
}
other => panic!("expected session catchup, got {other:?}"),
}
}
#[test]
fn cli_parses_ls_connector_bare() {
let cli = Cli::try_parse_from(["trusty-mpm", "ls"]).unwrap();
match cli.command.unwrap() {
Command::Ls {
terms,
projects,
json,
source_id,
current,
all,
root,
} => {
assert!(
terms.is_empty(),
"bare `tm ls` must have no positional terms"
);
assert!(!projects, "bare `tm ls` must not set --projects");
assert!(!json);
assert!(source_id.is_none());
assert!(!current);
assert!(!all);
assert!(root.is_none());
}
other => panic!("expected top-level ls, got {other:?}"),
}
}
#[test]
fn cli_parses_ls_projects() {
let cli = Cli::try_parse_from(["trusty-mpm", "ls", "--projects"]).unwrap();
match cli.command.unwrap() {
Command::Ls { projects, .. } => assert!(projects, "--projects must parse to true"),
other => panic!("expected top-level ls --projects, got {other:?}"),
}
}
#[test]
fn cli_parses_ls_projects_short() {
let cli = Cli::try_parse_from(["trusty-mpm", "ls", "-p"]).unwrap();
match cli.command.unwrap() {
Command::Ls { projects, .. } => assert!(projects, "-p must parse to true"),
other => panic!("expected top-level ls -p, got {other:?}"),
}
}
#[test]
fn cli_parses_ls_json() {
let cli = Cli::try_parse_from(["trusty-mpm", "ls", "--json"]).unwrap();
match cli.command.unwrap() {
Command::Ls { json, projects, .. } => {
assert!(json, "--json must parse to true");
assert!(!projects, "--json alone must not imply --projects");
}
other => panic!("expected top-level ls --json, got {other:?}"),
}
}
#[test]
fn cli_parses_ls_current() {
let cli = Cli::try_parse_from(["trusty-mpm", "ls", "--current"]).unwrap();
match cli.command.unwrap() {
Command::Ls {
current, source_id, ..
} => {
assert!(current, "--current must parse to true");
assert!(source_id.is_none());
}
other => panic!("expected top-level ls --current, got {other:?}"),
}
}
#[test]
fn cli_parses_ls_source_id() {
let cli = Cli::try_parse_from(["trusty-mpm", "ls", "--source-id", "owner/repo"]).unwrap();
match cli.command.unwrap() {
Command::Ls {
source_id, current, ..
} => {
assert_eq!(source_id, Some("owner/repo".to_string()));
assert!(!current);
}
other => panic!("expected top-level ls --source-id, got {other:?}"),
}
}
#[test]
fn cli_ls_source_id_and_current_conflict() {
let result =
Cli::try_parse_from(["trusty-mpm", "ls", "--current", "--source-id", "owner/repo"]);
assert!(
result.is_err(),
"passing both --current and --source-id to `tm ls` must be a parse error"
);
}
use crate::commands::session_picker::should_show_picker;
#[test]
fn ls_connector_should_show_picker_interactive_with_sessions() {
assert!(should_show_picker(true, true, false, false, 1));
assert!(should_show_picker(true, true, false, false, 5));
}
#[test]
fn ls_connector_should_show_picker_non_tty_static() {
assert!(
!should_show_picker(false, true, false, false, 3),
"piped stdin -> static"
);
assert!(
!should_show_picker(true, false, false, false, 3),
"piped stdout -> static"
);
assert!(!should_show_picker(false, false, false, false, 3));
}
#[test]
fn ls_connector_should_show_picker_flags_and_empty_static() {
assert!(
!should_show_picker(true, true, true, false, 3),
"--json -> static"
);
assert!(
!should_show_picker(true, true, false, true, 3),
"--all -> static"
);
assert!(
!should_show_picker(true, true, false, false, 0),
"0 sessions -> static"
);
}
use crate::commands::session_picker::{
SessionSortArg, filter_sessions_by_term, parse_ls_terms, sort_sessions,
};
#[test]
fn parse_ls_terms_empty_defaults_recent_no_filter() {
assert_eq!(parse_ls_terms(&[]), (SessionSortArg::Recent, None));
}
#[test]
fn parse_ls_terms_recent_keyword_only() {
let terms = vec!["recent".to_string()];
assert_eq!(parse_ls_terms(&terms), (SessionSortArg::Recent, None));
}
#[test]
fn parse_ls_terms_alpha_keyword_only() {
let terms = vec!["alpha".to_string()];
assert_eq!(parse_ls_terms(&terms), (SessionSortArg::Alpha, None));
}
#[test]
fn parse_ls_terms_keyword_case_insensitive() {
let terms = vec!["ALPHA".to_string()];
assert_eq!(parse_ls_terms(&terms), (SessionSortArg::Alpha, None));
let terms = vec!["Recent".to_string()];
assert_eq!(parse_ls_terms(&terms), (SessionSortArg::Recent, None));
}
#[test]
fn parse_ls_terms_recent_with_filter() {
let terms = vec!["recent".to_string(), "api".to_string()];
assert_eq!(
parse_ls_terms(&terms),
(SessionSortArg::Recent, Some("api".to_string()))
);
}
#[test]
fn parse_ls_terms_alpha_with_filter() {
let terms = vec!["alpha".to_string(), "api".to_string()];
assert_eq!(
parse_ls_terms(&terms),
(SessionSortArg::Alpha, Some("api".to_string()))
);
}
#[test]
fn parse_ls_terms_non_keyword_first_word_is_filter() {
let terms = vec!["foo".to_string()];
assert_eq!(
parse_ls_terms(&terms),
(SessionSortArg::Recent, Some("foo".to_string()))
);
}
#[test]
fn parse_ls_terms_multi_word_filter_without_keyword_joins_with_space() {
let terms = vec!["foo".to_string(), "bar".to_string()];
assert_eq!(
parse_ls_terms(&terms),
(SessionSortArg::Recent, Some("foo bar".to_string()))
);
}
#[test]
fn parse_ls_terms_keyword_then_filter_equal_to_other_keyword() {
let terms = vec!["recent".to_string(), "alpha".to_string()];
assert_eq!(
parse_ls_terms(&terms),
(SessionSortArg::Recent, Some("alpha".to_string()))
);
let terms = vec!["alpha".to_string(), "recent".to_string()];
assert_eq!(
parse_ls_terms(&terms),
(SessionSortArg::Alpha, Some("recent".to_string()))
);
}
fn ls_test_session(
name: &str,
state: &str,
last_activity_at: Option<&str>,
created_at: Option<&str>,
source_id: Option<&str>,
task: Option<&str>,
) -> trusty_mpm::client::ManagedSessionSummary {
trusty_mpm::client::ManagedSessionSummary {
id: format!("{name}-id"),
name: name.to_string(),
state: state.to_string(),
persisted_state: None,
workspace_path: None,
repo_url: None,
branch: None,
created_at: created_at.map(str::to_owned),
last_activity_at: last_activity_at.map(str::to_owned),
pending_decision: None,
proposed_default: None,
source_id: source_id.map(str::to_owned),
task: task.map(str::to_owned),
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: 0,
deleted: false,
}
}
#[test]
fn filter_sessions_by_term_matches_name() {
let sessions = vec![
ls_test_session("api-worker", "active", None, None, None, None),
ls_test_session("web-frontend", "active", None, None, None, None),
];
let out = filter_sessions_by_term(sessions, Some("API"));
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "api-worker");
}
#[test]
fn filter_sessions_by_term_matches_task() {
let sessions = vec![
ls_test_session("s1", "active", None, None, None, Some("fix the login bug")),
ls_test_session("s2", "active", None, None, None, Some("write docs")),
];
let out = filter_sessions_by_term(sessions, Some("login"));
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "s1");
}
#[test]
fn filter_sessions_by_term_matches_source_id() {
let sessions = vec![
ls_test_session(
"s1",
"active",
None,
None,
Some("bobmatnyc/trusty-tools"),
None,
),
ls_test_session("s2", "active", None, None, Some("other/repo"), None),
];
let out = filter_sessions_by_term(sessions, Some("trusty-tools"));
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "s1");
}
#[test]
fn filter_sessions_by_term_is_case_insensitive() {
let sessions = vec![ls_test_session("MyApp", "ACTIVE", None, None, None, None)];
assert_eq!(
filter_sessions_by_term(sessions.clone(), Some("myapp")).len(),
1
);
assert_eq!(filter_sessions_by_term(sessions, Some("active")).len(), 1);
}
#[test]
fn filter_sessions_by_term_no_match_returns_empty() {
let sessions = vec![ls_test_session("s1", "active", None, None, None, None)];
let out = filter_sessions_by_term(sessions, Some("nonexistent-substring"));
assert!(out.is_empty(), "no match must yield an empty result");
}
#[test]
fn filter_sessions_by_term_none_is_noop() {
let sessions = vec![
ls_test_session("s1", "active", None, None, None, None),
ls_test_session("s2", "stopped", None, None, None, None),
];
assert_eq!(filter_sessions_by_term(sessions, None).len(), 2);
}
#[test]
fn sort_sessions_recent_orders_by_last_activity() {
let mut sessions = vec![
ls_test_session(
"older",
"active",
Some("2026-01-01T00:00:00Z"),
None,
None,
None,
),
ls_test_session(
"newest",
"active",
Some("2026-07-01T00:00:00Z"),
None,
None,
None,
),
ls_test_session(
"middle",
"active",
Some("2026-04-01T00:00:00Z"),
None,
None,
None,
),
];
sort_sessions(&mut sessions, SessionSortArg::Recent);
let names: Vec<&str> = sessions.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["newest", "middle", "older"]);
}
#[test]
fn sort_sessions_recent_falls_back_to_created_at() {
let mut sessions = vec![
ls_test_session("no-timestamp", "active", None, None, None, None),
ls_test_session(
"created-only",
"active",
None,
Some("2026-03-01T00:00:00Z"),
None,
None,
),
ls_test_session(
"has-activity",
"active",
Some("2026-06-01T00:00:00Z"),
Some("2026-01-01T00:00:00Z"),
None,
None,
),
];
sort_sessions(&mut sessions, SessionSortArg::Recent);
let names: Vec<&str> = sessions.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["has-activity", "created-only", "no-timestamp"]);
}
#[test]
fn sort_sessions_alpha_orders_by_name_case_insensitive() {
let mut sessions = vec![
ls_test_session("Zebra", "active", None, None, None, None),
ls_test_session("apple", "active", None, None, None, None),
ls_test_session("Mango", "active", None, None, None, None),
];
sort_sessions(&mut sessions, SessionSortArg::Alpha);
let names: Vec<&str> = sessions.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["apple", "Mango", "Zebra"]);
}
#[test]
fn sort_sessions_recent_groups_attached_before_active_before_stopped() {
let mut stopped_but_newest = ls_test_session(
"stopped-newest",
"stopped",
Some("2026-07-01T00:00:00Z"),
None,
None,
None,
);
stopped_but_newest.attached = false;
let mut active_but_older = ls_test_session(
"active-older",
"active",
Some("2026-01-01T00:00:00Z"),
None,
None,
None,
);
active_but_older.attached = false;
let mut attached_but_oldest = ls_test_session(
"attached-oldest",
"active",
Some("2025-01-01T00:00:00Z"),
None,
None,
None,
);
attached_but_oldest.attached = true;
let mut sessions = vec![stopped_but_newest, active_but_older, attached_but_oldest];
sort_sessions(&mut sessions, SessionSortArg::Recent);
let names: Vec<&str> = sessions.iter().map(|s| s.name.as_str()).collect();
assert_eq!(
names,
vec!["attached-oldest", "active-older", "stopped-newest"],
"attached must lead regardless of recency, active must lead over \
stopped regardless of recency"
);
}
#[test]
fn sort_sessions_alpha_groups_attached_before_active_before_stopped() {
let mut stopped_but_a = ls_test_session("aaa-stopped", "stopped", None, None, None, None);
stopped_but_a.attached = false;
let mut active_but_m = ls_test_session("mmm-active", "active", None, None, None, None);
active_but_m.attached = false;
let mut attached_but_z = ls_test_session("zzz-attached", "active", None, None, None, None);
attached_but_z.attached = true;
let mut sessions = vec![stopped_but_a, active_but_m, attached_but_z];
sort_sessions(&mut sessions, SessionSortArg::Alpha);
let names: Vec<&str> = sessions.iter().map(|s| s.name.as_str()).collect();
assert_eq!(
names,
vec!["zzz-attached", "mmm-active", "aaa-stopped"],
"attached must lead regardless of name, active must lead over \
stopped regardless of name"
);
}
#[test]
fn filter_and_sort_combined() {
let sessions = vec![
ls_test_session(
"api-old",
"active",
Some("2026-01-01T00:00:00Z"),
None,
None,
None,
),
ls_test_session(
"web-new",
"active",
Some("2026-07-01T00:00:00Z"),
None,
None,
None,
),
ls_test_session(
"api-new",
"active",
Some("2026-06-01T00:00:00Z"),
None,
None,
None,
),
];
let mut filtered = filter_sessions_by_term(sessions, Some("api"));
assert_eq!(
filtered.len(),
2,
"only the two 'api' sessions survive the filter"
);
sort_sessions(&mut filtered, SessionSortArg::Recent);
let names: Vec<&str> = filtered.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["api-new", "api-old"]);
}
use crate::commands::session_picker::{find_slot, shown_slot, slots_are_stale};
#[test]
fn slots_are_stale_true_when_all_zero() {
let sessions = vec![
ls_test_session("s1", "active", None, None, None, None),
ls_test_session("s2", "active", None, None, None, None),
ls_test_session("s3", "active", None, None, None, None),
];
assert!(slots_are_stale(&sessions));
}
#[test]
fn slots_are_stale_false_when_any_nonzero() {
let mut sessions = vec![
ls_test_session("s1", "active", None, None, None, None),
ls_test_session("s2", "active", None, None, None, None),
];
sessions[1].slot = 2;
assert!(!slots_are_stale(&sessions));
}
#[test]
fn slots_are_stale_false_when_empty() {
assert!(!slots_are_stale(&[]));
}
#[test]
fn find_slot_healthy_looks_up_by_real_slot() {
let mut sessions = vec![
ls_test_session("s1", "active", None, None, None, None),
ls_test_session("s2", "active", None, None, None, None),
];
sessions[0].slot = 1;
sessions[1].slot = 5;
assert_eq!(find_slot(&sessions, 5), Some(1));
assert_eq!(find_slot(&sessions, 2), None);
}
#[test]
fn find_slot_stale_fallback_resolves_positionally() {
let sessions = vec![
ls_test_session("s1", "active", None, None, None, None),
ls_test_session("s2", "active", None, None, None, None),
ls_test_session("s3", "active", None, None, None, None),
];
assert_eq!(find_slot(&sessions, 1), Some(0));
assert_eq!(find_slot(&sessions, 2), Some(1));
assert_eq!(find_slot(&sessions, 3), Some(2));
assert_eq!(find_slot(&sessions, 4), None);
assert_eq!(find_slot(&sessions, 0), None);
}
#[test]
fn shown_slot_healthy_uses_real_slot() {
let mut sessions = vec![ls_test_session("s1", "active", None, None, None, None)];
sessions[0].slot = 7;
assert_eq!(shown_slot(&sessions, 0), 7);
}
#[test]
fn shown_slot_stale_fallback_is_distinct_and_incrementing() {
let sessions = vec![
ls_test_session("s1", "active", None, None, None, None),
ls_test_session("s2", "active", None, None, None, None),
ls_test_session("s3", "active", None, None, None, None),
];
assert_eq!(shown_slot(&sessions, 0), 1);
assert_eq!(shown_slot(&sessions, 1), 2);
assert_eq!(shown_slot(&sessions, 2), 3);
}
#[test]
fn cli_parses_ls_terms_recent_keyword() {
let cli = Cli::try_parse_from(["trusty-mpm", "ls", "recent"]).unwrap();
match cli.command.unwrap() {
Command::Ls { terms, .. } => assert_eq!(terms, vec!["recent".to_string()]),
other => panic!("expected top-level ls recent, got {other:?}"),
}
}
#[test]
fn cli_parses_ls_terms_alpha_with_filter() {
let cli = Cli::try_parse_from(["trusty-mpm", "ls", "alpha", "foo"]).unwrap();
match cli.command.unwrap() {
Command::Ls { terms, .. } => {
assert_eq!(terms, vec!["alpha".to_string(), "foo".to_string()])
}
other => panic!("expected top-level ls alpha foo, got {other:?}"),
}
}
#[test]
fn cli_parses_ls_terms_bare_filter_word() {
let cli = Cli::try_parse_from(["trusty-mpm", "ls", "somefilter"]).unwrap();
match cli.command.unwrap() {
Command::Ls { terms, .. } => assert_eq!(terms, vec!["somefilter".to_string()]),
other => panic!("expected top-level ls somefilter, got {other:?}"),
}
}
#[test]
fn cli_parses_ls_terms_with_source_id_flag() {
let cli = Cli::try_parse_from([
"trusty-mpm",
"ls",
"--source-id",
"owner/repo",
"alpha",
"foo",
])
.unwrap();
match cli.command.unwrap() {
Command::Ls {
terms, source_id, ..
} => {
assert_eq!(terms, vec!["alpha".to_string(), "foo".to_string()]);
assert_eq!(source_id, Some("owner/repo".to_string()));
}
other => panic!("expected top-level ls with source-id + terms, got {other:?}"),
}
}
#[test]
fn cli_parses_sessions_ls_terms_alpha_with_filter() {
let cli = Cli::try_parse_from(["trusty-mpm", "sessions", "ls", "alpha", "foo"]).unwrap();
match cli.command.unwrap() {
Command::Sessions {
action: SessionAction::Ls { terms, .. },
} => assert_eq!(terms, vec!["alpha".to_string(), "foo".to_string()]),
other => panic!("expected sessions ls alpha foo, got {other:?}"),
}
}
use crate::commands::picker_delete::{
ManagedDeleteNext, classify_managed_delete, confirm_is_force, confirm_is_yes,
delete_needs_force,
};
#[test]
fn delete_needs_force_running_true() {
assert!(delete_needs_force("active"));
assert!(delete_needs_force("provisioning"));
}
#[test]
fn delete_needs_force_stopped_and_errored_false() {
assert!(!delete_needs_force("stopped"));
assert!(!delete_needs_force("errored"));
}
#[test]
fn classify_managed_delete_maps_each_family() {
assert_eq!(
classify_managed_delete(reqwest::StatusCode::OK),
ManagedDeleteNext::Deleted
);
assert_eq!(
classify_managed_delete(reqwest::StatusCode::NOT_FOUND),
ManagedDeleteNext::FallbackLocal,
"404 -> project-session fallback (the other family)"
);
assert_eq!(
classify_managed_delete(reqwest::StatusCode::CONFLICT),
ManagedDeleteNext::Refused,
"409 -> running guard, never auto-force"
);
assert_eq!(
classify_managed_delete(reqwest::StatusCode::INTERNAL_SERVER_ERROR),
ManagedDeleteNext::Error
);
}
#[test]
fn confirm_is_yes_accepts_only_y_variants() {
assert!(confirm_is_yes("y"));
assert!(confirm_is_yes("Y"));
assert!(confirm_is_yes(" yes \n"));
assert!(!confirm_is_yes(""));
assert!(!confirm_is_yes("force"));
assert!(!confirm_is_yes("delete"));
}
#[test]
fn confirm_is_force_requires_the_word_force() {
assert!(confirm_is_force("force"));
assert!(confirm_is_force(" FORCE \n"));
assert!(!confirm_is_force("y"));
assert!(!confirm_is_force("yes"));
assert!(!confirm_is_force(""));
}
use std::net::SocketAddr;
use std::time::{Duration, Instant};
use trusty_mpm::core::session::{ControlModel, Session, SessionId, SessionStatus};
use trusty_mpm::daemon::state::DaemonState;
use crate::commands::picker_delete::{
DeleteReport, delete_managed_then_local, local_session_needs_force,
};
struct LocalTestServer {
url: String,
handle: tokio::task::JoinHandle<()>,
}
impl LocalTestServer {
async fn spawn(state: std::sync::Arc<DaemonState>) -> Self {
let app = trusty_mpm::daemon::api::router(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback port");
let addr: SocketAddr = listener.local_addr().expect("resolve bound addr");
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
let url = format!("http://{addr}");
Self::wait_for_health(&url).await;
Self { url, handle }
}
async fn wait_for_health(base: &str) {
let client = reqwest::Client::new();
let deadline = Instant::now() + Duration::from_secs(2);
let health = format!("{base}/health");
loop {
if let Ok(resp) = client.get(&health).send().await
&& resp.status().is_success()
{
return;
}
if Instant::now() >= deadline {
panic!("daemon at {base} did not become healthy within 2s");
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
}
impl Drop for LocalTestServer {
fn drop(&mut self) {
self.handle.abort();
}
}
async fn spawn_with_local_session(status: SessionStatus) -> (LocalTestServer, String) {
let state = DaemonState::shared();
let id = SessionId::new();
let mut session = Session::new(id, "/tmp/2304-fixture", ControlModel::Tmux, None);
session.status = status;
state.register_session(session);
let server = LocalTestServer::spawn(state).await;
(server, id.0.to_string())
}
#[tokio::test]
async fn local_delete_refuses_running_session_without_force() {
let (server, id) = spawn_with_local_session(SessionStatus::Active).await;
let client = reqwest::Client::new();
let report = delete_managed_then_local(&client, &server.url, &id, false)
.await
.expect("routing call must not error");
assert!(
matches!(report, DeleteReport::Refused(_)),
"running local session without --force must be Refused, got {report:?}"
);
let still_there = client
.get(format!("{}/sessions/{id}", server.url))
.send()
.await
.expect("get session")
.status();
assert_eq!(
still_there,
reqwest::StatusCode::OK,
"session must NOT have been deleted"
);
}
#[tokio::test]
async fn local_delete_allows_stopped_session_without_force() {
let (server, id) = spawn_with_local_session(SessionStatus::Stopped).await;
let client = reqwest::Client::new();
let report = delete_managed_then_local(&client, &server.url, &id, false)
.await
.expect("routing call must not error");
match report {
DeleteReport::Deleted { local, .. } => assert!(local, "must report the local fallback"),
other => panic!("expected Deleted, got {other:?}"),
}
let gone = client
.get(format!("{}/sessions/{id}", server.url))
.send()
.await
.expect("get session")
.status();
assert_eq!(gone, reqwest::StatusCode::NOT_FOUND, "session must be gone");
}
#[tokio::test]
async fn local_delete_force_bypasses_guard_on_running_session() {
let (server, id) = spawn_with_local_session(SessionStatus::Active).await;
let client = reqwest::Client::new();
let report = delete_managed_then_local(&client, &server.url, &id, true)
.await
.expect("routing call must not error");
match report {
DeleteReport::Deleted { local, .. } => assert!(local),
other => panic!("expected Deleted with force=true, got {other:?}"),
}
}
#[test]
fn local_session_needs_force_stopped_false() {
assert!(!local_session_needs_force(SessionStatus::Stopped));
}
#[test]
fn local_session_needs_force_active_and_others_true() {
assert!(local_session_needs_force(SessionStatus::Starting));
assert!(local_session_needs_force(SessionStatus::Active));
assert!(local_session_needs_force(SessionStatus::AwaitingApproval));
assert!(local_session_needs_force(SessionStatus::Detached));
assert!(local_session_needs_force(SessionStatus::Paused));
}
use crate::commands::session_picker_prune::auto_prune_dead_records_at;
async fn seed_dead_session(
mgr: &trusty_mpm::session_manager::SessionManager,
root: &std::path::Path,
label: &str,
) -> trusty_mpm::session_manager::ManagedSessionId {
let gone = root.join(format!("gone-workspace-{label}"));
let id = trusty_mpm::session_manager::ManagedSessionId::new();
mgr.create_with_id(
id,
format!("regression: auto-prune dead record {label}"),
Some(gone.clone()),
None,
Some(gone),
Some("https://example.com/r.git".to_string()),
Some("main".to_string()),
trusty_mpm::runtime::RuntimeKind::default(),
false,
false,
)
.await
.expect("seed dead session");
mgr.mark_errored(&id, "regression: simulate prior spawn failure")
.await
.expect("mark errored");
id
}
async fn fetch_raw_live(
client: &reqwest::Client,
url: &str,
) -> Vec<trusty_mpm::client::ManagedSessionSummary> {
let raw = crate::commands::session_picker::fetch_managed_raw(client, url, None)
.await
.expect("fetch raw");
crate::commands::session_picker::parse_scoped_sessions(&raw, false).expect("parse")
}
#[tokio::test]
async fn auto_prune_dead_records_first_sighting_is_not_pruned() {
let root = tempfile::TempDir::new().expect("tempdir");
let state = std::sync::Arc::new(
DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await,
);
let mgr = state.session_manager().await;
let id = seed_dead_session(&mgr, root.path(), "first").await;
let server = LocalTestServer::spawn(state).await;
let client = reqwest::Client::new();
let marker_path = root.path().join("seen.json");
let sessions = fetch_raw_live(&client, &server.url).await;
let target = sessions
.iter()
.find(|s| s.id == id.to_string())
.expect("dead session must still surface on the raw fetch");
assert!(target.unresumable, "seeded session must be unresumable");
let outcome = auto_prune_dead_records_at(&client, &server.url, sessions, &marker_path).await;
assert_eq!(outcome.pruned, 0, "a first sighting must never be pruned");
assert_eq!(outcome.pending, 1, "a first sighting is reported pending");
assert!(
outcome.kept.iter().any(|s| s.id == id.to_string()),
"the record must remain visible while awaiting confirmation"
);
let still_there = fetch_raw_live(&client, &server.url).await;
let record = still_there
.iter()
.find(|s| s.id == id.to_string())
.expect("record must still be live");
assert_eq!(record.state, "errored");
}
#[tokio::test]
async fn auto_prune_dead_records_removes_confirmed_unresumable_records() {
let root = tempfile::TempDir::new().expect("tempdir");
let state = std::sync::Arc::new(
DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await,
);
let mgr = state.session_manager().await;
let id = seed_dead_session(&mgr, root.path(), "confirmed").await;
let server = LocalTestServer::spawn(state).await;
let client = reqwest::Client::new();
let marker_path = root.path().join("seen.json");
let sessions = fetch_raw_live(&client, &server.url).await;
let first = auto_prune_dead_records_at(&client, &server.url, sessions, &marker_path).await;
assert_eq!(first.pruned, 0);
let sessions = fetch_raw_live(&client, &server.url).await;
let second = auto_prune_dead_records_at(&client, &server.url, sessions, &marker_path).await;
assert_eq!(
second.pruned, 1,
"confirmed on a second listing must be pruned"
);
assert_eq!(second.pending, 0);
assert!(
!second.kept.iter().any(|s| s.id == id.to_string()),
"the pruned record must not remain in the returned list"
);
let refetched = fetch_raw_live(&client, &server.url).await;
assert!(
!refetched.iter().any(|s| s.id == id.to_string()),
"the decommissioned record must no longer appear in the live listing"
);
}
#[tokio::test]
async fn auto_prune_dead_records_keeps_workspace_present_records() {
let root = tempfile::TempDir::new().expect("tempdir");
let state = std::sync::Arc::new(
DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await,
);
let mgr = state.session_manager().await;
let ws = root.path().join("still-here-workspace");
std::fs::create_dir_all(&ws).expect("create real workspace dir");
let id = trusty_mpm::session_manager::ManagedSessionId::new();
mgr.create_with_id(
id,
"regression: auto-prune keeps live workspace".to_string(),
Some(ws.clone()),
None,
Some(ws),
Some("https://example.com/r.git".to_string()),
Some("main".to_string()),
trusty_mpm::runtime::RuntimeKind::default(),
false,
false,
)
.await
.expect("seed session with real workspace");
mgr.stop(&id)
.await
.expect("stop session (workspace intact)");
let server = LocalTestServer::spawn(state).await;
let client = reqwest::Client::new();
let marker_path = root.path().join("seen.json");
let sessions = fetch_raw_live(&client, &server.url).await;
let target = sessions
.iter()
.find(|s| s.id == id.to_string())
.expect("stopped session with a real workspace must surface");
assert!(
!target.unresumable,
"a stopped session whose workspace still exists on disk must never \
be flagged unresumable"
);
let outcome = auto_prune_dead_records_at(&client, &server.url, sessions, &marker_path).await;
assert_eq!(
outcome.pruned, 0,
"a record with an existing workspace must never be pruned"
);
assert_eq!(outcome.pending, 0);
assert!(
outcome.kept.iter().any(|s| s.id == id.to_string()),
"the record must survive auto-prune untouched"
);
}
#[tokio::test]
async fn auto_prune_dead_records_is_noop_when_nothing_is_dead() {
let client = reqwest::Client::new();
let marker_path = tempfile::TempDir::new()
.expect("tempdir")
.path()
.join("seen.json");
let sessions = vec![ls_test_session("healthy", "active", None, None, None, None)];
let outcome =
auto_prune_dead_records_at(&client, "http://127.0.0.1:1", sessions, &marker_path).await;
assert_eq!(outcome.pruned, 0);
assert_eq!(outcome.pending, 0);
assert_eq!(outcome.kept.len(), 1);
assert_eq!(outcome.kept[0].name, "healthy");
}
#[tokio::test]
async fn auto_prune_dead_records_honors_the_cap() {
let root = tempfile::TempDir::new().expect("tempdir");
let state = std::sync::Arc::new(
DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await,
);
let mgr = state.session_manager().await;
for i in 0..6 {
seed_dead_session(&mgr, root.path(), &format!("cap-{i}")).await;
}
let server = LocalTestServer::spawn(state).await;
let client = reqwest::Client::new();
let marker_path = root.path().join("seen.json");
let sessions = fetch_raw_live(&client, &server.url).await;
assert_eq!(sessions.len(), 6);
let first = auto_prune_dead_records_at(&client, &server.url, sessions, &marker_path).await;
assert_eq!(first.pruned, 0);
assert_eq!(first.pending, 6);
let sessions = fetch_raw_live(&client, &server.url).await;
assert_eq!(sessions.len(), 6);
let second = auto_prune_dead_records_at(&client, &server.url, sessions, &marker_path).await;
assert_eq!(second.pruned, 5, "the cap must limit a single call to 5");
assert_eq!(
second.pending, 1,
"the 6th confirmed record must be reported pending, not silently dropped"
);
assert_eq!(second.kept.len(), 1);
}
#[tokio::test]
async fn auto_prune_dead_records_stops_sweep_when_daemon_reports_workspace_removed() {
async fn stub_decommission(
axum::extract::Path(_id): axum::extract::Path<String>,
) -> axum::Json<serde_json::Value> {
axum::Json(serde_json::json!({ "workspace_removed": true }))
}
let app = axum::Router::new().route(
"/api/v1/sessions/managed/{id}/decommission",
axum::routing::post(stub_decommission),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback port");
let addr = listener.local_addr().expect("resolve bound addr");
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
let url = format!("http://{addr}");
let client = reqwest::Client::new();
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let mut seen = std::collections::HashMap::new();
seen.insert("id-a".to_string(), "2020-01-01T00:00:00Z".to_string());
seen.insert("id-b".to_string(), "2020-01-01T00:00:00Z".to_string());
std::fs::write(&marker_path, serde_json::to_string(&seen).unwrap()).unwrap();
let mut a = ls_test_session("id-a", "errored", None, None, None, None);
a.id = "id-a".to_string();
a.unresumable = true;
let mut b = ls_test_session("id-b", "errored", None, None, None, None);
b.id = "id-b".to_string();
b.unresumable = true;
let outcome = auto_prune_dead_records_at(&client, &url, vec![a, b], &marker_path).await;
assert_eq!(
outcome.pruned, 0,
"a stale daemon's workspace_removed=true must never count as pruned"
);
assert_eq!(
outcome.kept.len(),
2,
"both records must be left on the manual [d<N>] path once the stale \
daemon is detected"
);
assert_eq!(
outcome.pending, 2,
"both stale-daemon-held records must fold into `pending`, not vanish \
from the reported count"
);
handle.abort();
}
#[tokio::test]
async fn auto_prune_dead_records_stale_daemon_sentinel_expires_after_ttl() {
async fn stub_decommission(
axum::extract::Path(_id): axum::extract::Path<String>,
) -> axum::Json<serde_json::Value> {
axum::Json(serde_json::json!({ "workspace_removed": true }))
}
let app = axum::Router::new().route(
"/api/v1/sessions/managed/{id}/decommission",
axum::routing::post(stub_decommission),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback port");
let addr = listener.local_addr().expect("resolve bound addr");
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
let url = format!("http://{addr}");
let client = reqwest::Client::new();
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let confirmed_session = || {
let mut s = ls_test_session("id-a", "errored", None, None, None, None);
s.id = "id-a".to_string();
s.unresumable = true;
s
};
let mut seen = std::collections::HashMap::new();
seen.insert("id-a".to_string(), "2020-01-01T00:00:00Z".to_string());
seen.insert(
"__stale_daemon_detected__".to_string(),
chrono::Utc::now().to_rfc3339(),
);
std::fs::write(&marker_path, serde_json::to_string(&seen).unwrap()).unwrap();
let outcome =
auto_prune_dead_records_at(&client, &url, vec![confirmed_session()], &marker_path).await;
assert_eq!(outcome.pruned, 0, "a fresh sentinel must still block");
assert_eq!(outcome.kept.len(), 1);
assert_eq!(outcome.pending, 1, "held record must fold into pending");
let mut seen = std::collections::HashMap::new();
seen.insert("id-a".to_string(), "2020-01-01T00:00:00Z".to_string());
seen.insert(
"__stale_daemon_detected__".to_string(),
"2020-01-01T00:00:00Z".to_string(),
);
std::fs::write(&marker_path, serde_json::to_string(&seen).unwrap()).unwrap();
let outcome =
auto_prune_dead_records_at(&client, &url, vec![confirmed_session()], &marker_path).await;
assert_eq!(
outcome.pruned, 0,
"the stub is still stale, so the retry must not count as pruned"
);
assert_eq!(outcome.kept.len(), 1);
let raw = std::fs::read_to_string(&marker_path).expect("read marker file");
let persisted: std::collections::HashMap<String, String> =
serde_json::from_str(&raw).expect("parse marker file");
let sentinel_ts = persisted
.get("__stale_daemon_detected__")
.expect("sentinel must be re-written after retry");
let parsed = chrono::DateTime::parse_from_rfc3339(sentinel_ts).expect("valid RFC 3339");
assert!(
chrono::Utc::now().signed_duration_since(parsed) < chrono::Duration::minutes(1),
"the re-tripped sentinel must be freshly timestamped, not the expired one"
);
handle.abort();
}
#[tokio::test]
async fn fetch_live_sessions_skips_auto_prune_when_disallowed() {
let root = tempfile::TempDir::new().expect("tempdir");
let state = std::sync::Arc::new(
DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await,
);
let mgr = state.session_manager().await;
let id = seed_dead_session(&mgr, root.path(), "non-tty").await;
let server = LocalTestServer::spawn(state).await;
let client = reqwest::Client::new();
let sessions = crate::commands::session_picker::fetch_live_sessions(
&client,
&server.url,
None,
false,
false, )
.await
.expect("fetch live sessions");
assert!(
sessions.iter().any(|s| s.id == id.to_string()),
"a non-interactive fetch must return the dead record unchanged"
);
let still_there = fetch_raw_live(&client, &server.url).await;
let record = still_there
.iter()
.find(|s| s.id == id.to_string())
.expect("record must still be live");
assert_eq!(
record.state, "errored",
"a non-interactive fetch must never decommission anything"
);
}