trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
//! CLI parse tests for the `tm session` -> `tm sessions` top-level rename
//! (issue #2116, DOC-35 §2.2/§3.2) — extracted from `tests.rs` to keep it
//! under the 1500-SLOC test-file cap, following the existing
//! `tests_behavior_a/b/c/d` split convention.
//!
//! Why: `cli_parses_session_singular` in `tests.rs` already covers the
//! deprecated singular alias parsing unchanged; this file adds the mirror
//! assertions for the new canonical plural (`cli_parses_sessions_plural_canonical`),
//! the full-verb-surface parity check (`cli_session_and_sessions_agree_for_every_verb`),
//! and the pure deprecation-message-text assertion
//! (`top_level_alias_notice_message`) — the process-level "printed exactly
//! once" property is proven separately by the `tm_sessions_alias_notice`
//! integration test, which spawns the real binary.
//! What: parse round-trips for `Command::Sessions` and a full sweep of every
//! `SessionAction` verb asserting `tm session <verb>` and `tm sessions <verb>`
//! parse to the identical action.
//!
//! Also carries the `#1809`/`#3034`/`#3044` decommissioned/deleted-tombstone
//! picker-filter coverage (`is_live_session_state_*`, `picker_filter_*`),
//! moved here from `tests_behavior_c_tests.rs` when the #3044 reconciliation's
//! added tests pushed that file over the 1500-SLOC test cap — this file had
//! headroom under the same `tests_behavior_c/d/e` split convention. #4994 adds
//! the dead-record (`unresumable`) half of the same default-view filter
//! alongside it.
//! Test: `cargo test -p trusty-mpm` runs this file as part of the `tm` binary
//! test suite.

use clap::Parser;

use crate::cli::{Cli, Command, SessionAction};
use crate::commands::managed::{filter_live_sessions, is_live_session_state};

#[test]
fn cli_parses_session_decommission_force() {
    // #7660: `--force` excuses tm's own provisioning files.
    let cli =
        Cli::try_parse_from(["trusty-mpm", "session", "decommission", "abc", "--force"]).unwrap();
    match cli.command.unwrap() {
        Command::Session {
            action: SessionAction::Decommission { id, force },
        } => assert!(id == "abc" && force),
        other => panic!("expected session decommission --force, got {other:?}"),
    }
}

#[test]
fn cli_parses_sessions_plural_canonical() {
    // #2116: `sessions` (plural) is now the canonical top-level spelling —
    // the mirror image of `cli_parses_session_singular` in `tests.rs`, parsing
    // into the new `Command::Sessions` variant.
    let cli = Cli::try_parse_from(["trusty-mpm", "sessions", "list"]).unwrap();
    match cli.command.unwrap() {
        Command::Sessions {
            action: SessionAction::List { dir },
        } => assert_eq!(dir, None),
        other => panic!("expected sessions list, got {other:?}"),
    }
}

#[test]
fn cli_session_and_sessions_agree_for_every_verb() {
    // #2116: proves `tm session <verb>` (deprecated alias) and `tm sessions
    // <verb>` (canonical) resolve to the IDENTICAL `SessionAction` for every
    // verb the enum carries — zero functional difference between the two
    // top-level spellings, only the notice in `emit_top_level_alias_notice`
    // differs. Comparing `{:?}` output sidesteps `SessionAction` not deriving
    // `PartialEq` (it only derives `Debug` via `#[derive(Debug, Subcommand)]`).
    let cases: &[&[&str]] = &[
        &["start"],
        &["stop", "id-1"],
        &["kill", "id-1"],
        &["list"],
        &["tui"],
        &["clean"],
        &["info", "id-1"],
        &["instructions"],
        &["events", "id-1"],
        &["breakers"],
        &["pause", "id-1"],
        &["resume", "id-1"],
        &["run", "id-1", "echo hi"],
        &["output", "id-1"],
        &["new", "https://example.com/o/r.git", "--task", "t"],
        &["ls"],
        &["activity", "id-1"],
        &["send", "id-1", "text"],
        &["answer", "id-1", "yes"],
        &["attach", "id-1"],
        &["managed-stop", "id-1"],
        &["runtime-stop", "id-1"],
        &["managed-resume", "id-1"],
        &["decommission", "id-1"],
        &["delete", "id-1"],
        &["prune-idle"],
        &["decommission-ephemeral"],
        &["catchup"],
        &["prune", "--state", "all"],
        &["prune-worktrees"],
    ];
    for verb_args in cases {
        let mut singular_args = vec!["trusty-mpm", "session"];
        singular_args.extend_from_slice(verb_args);
        let mut plural_args = vec!["trusty-mpm", "sessions"];
        plural_args.extend_from_slice(verb_args);

        let singular_action = match Cli::try_parse_from(singular_args)
            .unwrap_or_else(|e| panic!("`tm session {verb_args:?}` failed to parse: {e}"))
            .command
            .unwrap()
        {
            Command::Session { action } => action,
            other => panic!("expected Command::Session for {verb_args:?}, got {other:?}"),
        };
        let plural_action = match Cli::try_parse_from(plural_args)
            .unwrap_or_else(|e| panic!("`tm sessions {verb_args:?}` failed to parse: {e}"))
            .command
            .unwrap()
        {
            Command::Sessions { action } => action,
            other => panic!("expected Command::Sessions for {verb_args:?}, got {other:?}"),
        };
        assert_eq!(
            format!("{singular_action:?}"),
            format!("{plural_action:?}"),
            "session/sessions parsed to different actions for {verb_args:?}"
        );
    }
}

/// The singular alias's `list` reaches the LIST verb, never a start/launch one.
///
/// Why (#7805): the alias was reported as starting a session instead of
/// listing. It does not — the reported `-------- Starting <path>` line was the
/// list row itself, with its id column blanked by the `short_id` defect this
/// issue's fix corrects (`short_id_reads_the_daemon_string_wire_form`). This is
/// the standing guard the issue asks for: the alias must keep resolving `list`
/// to [`SessionAction::List`], so the dispatcher in `commands::session::session`
/// can only take the read-only `GET /sessions` branch. It must never land on
/// `Start`/`New` (which provision and spawn tmux) or on
/// [`Command::External`], whose catch-all would treat `session` as a
/// registry/repo token and `list` as a trailing argument.
/// Test: this function IS the test.
#[test]
fn session_alias_list_routes_to_the_list_verb_never_a_start() {
    let command = Cli::try_parse_from(["trusty-mpm", "session", "list"])
        .expect("`tm session list` must parse")
        .command
        .expect("a subcommand must be present");
    match command {
        Command::Session {
            action: SessionAction::List { dir },
        } => assert_eq!(dir, None, "no `--dir` means the cwd, not a positional name"),
        Command::Session { action } => panic!(
            "`tm session list` must route to the List verb, got {action:?} — a start/launch \
             verb here is #7805"
        ),
        Command::External(tokens) => panic!(
            "`session` must match its own hidden subcommand, never the external catch-all \
             (got {tokens:?})"
        ),
        other => panic!("expected Command::Session, got {other:?}"),
    }
}

#[test]
fn top_level_alias_notice_message() {
    // #2116: the top-level `session` -> `sessions` alias reuses the shared
    // `deprecation_message` builder from the #1205 verb-level precedent — pure
    // message-text assertion, mirroring `deprecation_notice_format` in
    // `tests.rs`, since the `eprintln!` side effect itself is untestable here
    // (covered by the `tm_sessions_alias_notice` integration test spawning the
    // real binary).
    use crate::commands::managed::deprecation_message;
    assert_eq!(
        deprecation_message("session", "sessions"),
        "warning: 'session' is deprecated; use 'sessions'"
    );
}

/// #2577 review (CRITICAL finding 1): `unresumable_remedy_line`'s printed text
/// must cite ONLY real `tm session <verb>` subcommands — this is exactly how a
/// nonexistent `tm session rm <id>` shipped in a prior draft (the reviewer
/// built the branch and confirmed `tm session rm` fails with "unrecognized
/// subcommand 'rm'"). Parsing the literal tokens through the real `Cli` is a
/// stronger guarantee than eyeballing the string: it fails the moment a verb
/// is renamed or removed, not just when a typo is introduced.
///
/// Why: a plain substring assertion (`msg.contains("delete")`) would not have
/// caught the original bug — the text also had to be GRAMMATICALLY a real
/// invocation, which only an actual parse proves.
/// What: for each `reason` branch, extracts the exact `tm session …` argv
/// implied by the remedy text and asserts `Cli::try_parse_from` accepts it;
/// also asserts the literal substring `"session rm"` never appears in ANY
/// branch's output (the specific dead verb that shipped).
/// Test: this function IS the test.
#[test]
fn unresumable_remedy_line_cites_real_subcommands() {
    use crate::commands::guided_resume::unresumable_remedy_line;

    let id = "6ca3950b-90d5-4367-94c7-68576b61dafa";

    // workspace_missing → `tm session delete <id> --force` must parse as a
    // real Delete action with force=true.
    let workspace_missing = unresumable_remedy_line(id, Some("workspace_missing"));
    assert!(
        !workspace_missing.contains("session rm"),
        "must never cite the nonexistent `tm session rm` verb, got: {workspace_missing:?}"
    );
    let cli = Cli::try_parse_from(["trusty-mpm", "session", "delete", id, "--force"])
        .expect("the exact verb cited by the workspace_missing remedy must parse");
    match cli.command.unwrap() {
        Command::Session {
            action:
                SessionAction::Delete {
                    id: parsed_id,
                    force,
                },
        } => {
            assert_eq!(parsed_id, id);
            assert!(force, "the cited invocation must pass --force");
        }
        other => panic!("expected session delete, got {other:?}"),
    }

    // pane_gone → must warn before `tm session decommission <id>` (a REAL verb)
    // without instructing the operator to run it outright, and must never
    // recommend the destructive delete/decommission verbs unconditionally.
    let pane_gone = unresumable_remedy_line(id, Some("pane_gone"));
    assert!(
        !pane_gone.contains("session rm"),
        "must never cite the nonexistent `tm session rm` verb, got: {pane_gone:?}"
    );
    assert!(
        pane_gone.contains("sibling window"),
        "pane_gone remedy must explain the sibling-window hazard, got: {pane_gone:?}"
    );
    assert!(
        pane_gone.contains("tmux list-panes") || pane_gone.contains("session info"),
        "pane_gone remedy must point at an inspection step before any teardown, got: {pane_gone:?}"
    );
    let cli = Cli::try_parse_from(["trusty-mpm", "session", "decommission", id])
        .expect("the decommission verb cited by the pane_gone remedy must parse");
    assert!(matches!(
        cli.command.unwrap(),
        Command::Session {
            action: SessionAction::Decommission { .. }
        }
    ));
    let cli_info = Cli::try_parse_from(["trusty-mpm", "session", "info", id])
        .expect("the info verb cited by the pane_gone remedy must parse");
    assert!(matches!(
        cli_info.command.unwrap(),
        Command::Session {
            action: SessionAction::Info { .. }
        }
    ));

    // Unknown/absent reason (e.g. an older daemon) → conservative fallback that
    // names only read-only verbs, never delete/decommission.
    let fallback = unresumable_remedy_line(id, None);
    assert!(
        !fallback.contains("session rm"),
        "must never cite the nonexistent `tm session rm` verb, got: {fallback:?}"
    );
    assert!(
        !fallback.contains("delete") && !fallback.contains("decommission"),
        "the no-reason fallback must not suggest a destructive verb, got: {fallback:?}"
    );
}

/// #2577 review (optional LOW finding): `truncate_for_display` must leave a
/// normal-length daemon error body untouched.
#[test]
fn truncate_for_display_leaves_short_bodies_unchanged() {
    use crate::commands::guided_resume::truncate_for_display;

    let short = "workspace directory /gone no longer exists";
    assert_eq!(truncate_for_display(short), short);
}

/// #2577 review (optional LOW finding): an oversized daemon error body must be
/// capped rather than flooding the operator's terminal scrollback.
#[test]
fn truncate_for_display_caps_long_bodies() {
    use crate::commands::guided_resume::truncate_for_display;

    let long = "x".repeat(5000);
    let result = truncate_for_display(&long);
    assert!(
        result.chars().count() < long.chars().count(),
        "an oversized body must be shortened"
    );
    assert!(
        result.ends_with("… (truncated)"),
        "a truncated body must be marked as such, got tail: {:?}",
        &result[result.len().saturating_sub(20)..]
    );
}

// ── #1809: decommissioned-tombstone filter ────────────────────────────────────

#[test]
fn picker_filter_live_state_excludes_decommissioned() {
    // Why (#1809): `is_live_session_state` is the canonical predicate for
    // "should this session appear in the picker / sessions list by default?".
    // Test: concrete state → expected bool, not derived from the same expression.
    // None of these are #3034 slot tombstones, so `is_slot_tombstone` is `false`
    // throughout — it is only consulted when `state == "deleted"`.
    assert!(
        !is_live_session_state("decommissioned", false),
        "decommissioned must be excluded from default view"
    );
    // Active sessions must always be visible.
    assert!(
        is_live_session_state("active", false),
        "active must be included in default view"
    );
    // Stopped/errored sessions can still be resumed — they must show.
    assert!(
        is_live_session_state("stopped", false),
        "stopped must be included in default view"
    );
    assert!(
        is_live_session_state("errored", false),
        "errored must be included in default view"
    );
    // Provisioning sessions are in-flight — they must show.
    assert!(
        is_live_session_state("provisioning", false),
        "provisioning must be included in default view"
    );
}

#[test]
fn is_live_session_state_excludes_soft_deleted_record() {
    // Reconciles the #3302 hardening commit (51243ea5, code-critic CRITICAL)
    // with #3034/#3044: a `"deleted"` row backed by a REAL, still-in-store
    // record (soft-deleted via `tm sessions delete`, #2012 — NOT a #3034
    // numbered-slot tombstone, so `is_slot_tombstone` is `false`) must stay
    // excluded from the default picker/list exactly like `decommissioned`, so
    // it is never offered as a resume target (which would resurrect it).
    assert!(
        !is_live_session_state("deleted", false),
        "a soft-deleted, still-in-store record must be excluded from the \
         default picker/list view"
    );
    assert!(!is_live_session_state("decommissioned", false));
    assert!(is_live_session_state("active", false));
    assert!(is_live_session_state("stopped", false));
}

#[test]
fn is_live_session_state_hides_slot_tombstone_from_default_view() {
    // Why (#5952): a #3034 slot tombstone — the daemon's `tombstone_summary`
    // placeholder for a slot whose record left the store — carries wire
    // `state == "deleted"` like the soft-deleted record above, and is now
    // hidden alongside it. 44 of the owner's 130 rows were these placeholders.
    // The predicate no longer takes an `is_slot_tombstone` input at all: the
    // two rows it used to separate now share one outcome.
    assert!(
        !is_live_session_state("deleted", false),
        "a #3034 slot tombstone must be hidden from the default view"
    );
    // #3034 is preserved elsewhere, not here: the slot registry never reuses
    // the number, the hidden slot still shows as a gap in the NUM column, and
    // `--all` still renders the row at its original slot — pinned by
    // `scope_for_display_hides_tombstone_by_default_and_all_shows_it`.
}

#[test]
fn picker_filter_excludes_decommissioned_keeps_active() {
    // Why (#1809): `filter_live_sessions` must drop decommissioned tombstones and
    // retain all other states. We construct a mixed slice and assert concrete counts
    // and membership — not the same expression used to compute the filter.
    let sessions: Vec<trusty_mpm::client::ManagedSessionSummary> =
        serde_json::from_value(serde_json::json!([
            { "id": "a1", "name": "sess-active",        "state": "active" },
            { "id": "b2", "name": "sess-dead-1",        "state": "decommissioned" },
            { "id": "c3", "name": "sess-stopped",       "state": "stopped" },
            { "id": "d4", "name": "sess-dead-2",        "state": "decommissioned" },
            { "id": "e5", "name": "sess-provisioning",  "state": "provisioning" },
        ]))
        .expect("test data must deserialize");

    let filtered = filter_live_sessions(sessions, &std::collections::HashSet::new());

    // Exactly 3 of the 5 sessions survive the filter.
    assert_eq!(
        filtered.len(),
        3,
        "filter must keep exactly 3 live sessions (active, stopped, provisioning)"
    );
    // Active session must be present.
    assert!(
        filtered.iter().any(|s| s.state == "active"),
        "active session must survive filter"
    );
    // Stopped session must be present (can be resumed).
    assert!(
        filtered.iter().any(|s| s.state == "stopped"),
        "stopped session must survive filter"
    );
    // Provisioning session must be present (in-flight).
    assert!(
        filtered.iter().any(|s| s.state == "provisioning"),
        "provisioning session must survive filter"
    );
    // Neither decommissioned session must appear.
    assert!(
        !filtered.iter().any(|s| s.state == "decommissioned"),
        "decommissioned tombstones must be excluded"
    );
}

#[test]
fn picker_filter_all_live_sessions_unchanged() {
    // Why: when no sessions are decommissioned, `filter_live_sessions` must
    // return all sessions unchanged — no unexpected truncation.
    let sessions: Vec<trusty_mpm::client::ManagedSessionSummary> =
        serde_json::from_value(serde_json::json!([
            { "id": "x1", "name": "sess-a", "state": "active" },
            { "id": "x2", "name": "sess-b", "state": "stopped" },
            { "id": "x3", "name": "sess-c", "state": "errored" },
        ]))
        .expect("test data must deserialize");

    let filtered = filter_live_sessions(sessions, &std::collections::HashSet::new());
    assert_eq!(
        filtered.len(),
        3,
        "all-live input must pass through unchanged (3 sessions)"
    );
}

#[test]
fn picker_filter_all_decommissioned_returns_empty() {
    // Why: if every session is decommissioned, the picker must show an empty list
    // (not crash or return some sessions).
    let sessions: Vec<trusty_mpm::client::ManagedSessionSummary> =
        serde_json::from_value(serde_json::json!([
            { "id": "z1", "name": "old-1", "state": "decommissioned" },
            { "id": "z2", "name": "old-2", "state": "decommissioned" },
        ]))
        .expect("test data must deserialize");

    let filtered = filter_live_sessions(sessions, &std::collections::HashSet::new());
    assert!(
        filtered.is_empty(),
        "all-decommissioned input must produce empty list"
    );
}

#[test]
fn picker_filter_hides_slot_tombstone_and_soft_deleted_record() {
    // Why (#5952): both rows below serialize wire `state == "deleted"` and only
    // the slot-tombstone row sets the dedicated `deleted: bool` field. Before
    // #5952 the filter told them apart to keep the tombstone visible; now both
    // are hidden from the default view and both return under `--all`.
    let sessions: Vec<trusty_mpm::client::ManagedSessionSummary> =
        serde_json::from_value(serde_json::json!([
            { "id": "a1", "name": "sess-active", "state": "active" },
            // #3034 numbered-slot tombstone.
            { "id": "", "name": "", "state": "deleted", "slot": 2, "deleted": true },
            // #3302 soft-deleted, still-in-store record.
            { "id": "b2", "name": "sess-soft-deleted", "state": "deleted" },
        ]))
        .expect("test data must deserialize");

    let filtered = filter_live_sessions(sessions, &std::collections::HashSet::new());

    assert_eq!(
        filtered.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
        vec!["a1"],
        "only the active session survives the default view"
    );
}

// ── #4994: the default view hides what the SWEEP classified dead ─────────────
//
// 🔴 Read `classified_dead` literally in every assertion below: it is the
// listing-time prune's own verdict, never the wire's `unresumable` flag. The
// two differ on a live session — see `picker_filter_keeps_a_live_row_flagged_
// unresumable`, which is the case that made PR #4995's first cut a CRITICAL.

#[test]
fn is_live_session_state_hides_a_record_the_prune_classified_dead() {
    // Why (#4994): the reported defect. A record whose tmux pane is gone AND
    // whose workspace directory no longer exists anywhere on disk is classified
    // dead by the sweep and rendered `[dead]`, yet still took the catch-all
    // `true` arm into the default `tm ls`. Six such rows sat in the owner's
    // list. `classified_dead` is the ONLY input that differs between these
    // assertions and `is_live_session_state_keeps_resumable_stopped_record`.
    assert!(
        !is_live_session_state("stopped", true),
        "a stopped record the sweep classified dead must be hidden from the default view"
    );
    assert!(
        !is_live_session_state("errored", true),
        "an errored record the sweep classified dead must be hidden from the default view"
    );
}

#[test]
fn is_live_session_state_keeps_resumable_stopped_record() {
    // 🔴 The regression guard for the hiding rule above. MOST stopped records
    // are legitimately resumable — 18 on the reporting machine against 6 dead
    // ones. Hiding a stopped record on its STATE rather than on the sweep's
    // verdict would take those 18 with it.
    assert!(
        is_live_session_state("stopped", false),
        "a stopped record with an intact workspace is resumable and must stay visible"
    );
    assert!(
        is_live_session_state("errored", false),
        "an errored record with an intact workspace must stay visible"
    );
    assert!(
        is_live_session_state("active", false),
        "an active record must stay visible"
    );
}

#[test]
fn is_live_session_state_hides_slot_tombstone_whatever_the_sweep_said() {
    // Why (#3034 x #4994 x #5952): the two hiding rules meet on one row. A slot
    // tombstone has no record for the sweep to classify, so `classified_dead`
    // can arrive either way; neither value may make the row reappear.
    assert!(!is_live_session_state("deleted", true));
    assert!(!is_live_session_state("deleted", false));
    // The soft-deleted-record and decommissioned rules are unmoved.
    assert!(!is_live_session_state("decommissioned", false));
}

#[test]
fn picker_filter_hides_only_what_the_prune_classified_dead() {
    // The list-level mirror: a mixed fleet where the two stopped rows differ
    // ONLY by membership in `dead_ids`, so "the row is absent" can only be
    // explained by the sweep's verdict.
    let sessions: Vec<trusty_mpm::client::ManagedSessionSummary> =
        serde_json::from_value(serde_json::json!([
            { "id": "a1", "name": "sess-active",   "state": "active" },
            { "id": "b2", "name": "sess-alive",    "state": "stopped" },
            { "id": "c3", "name": "sess-dead",     "state": "stopped" },
            { "id": "d4", "name": "sess-dead-err", "state": "errored" },
            { "id": "",   "name": "",              "state": "deleted", "slot": 5, "deleted": true },
        ]))
        .expect("test data must deserialize");

    let dead: std::collections::HashSet<String> =
        ["c3".to_string(), "d4".to_string()].into_iter().collect();
    let filtered = filter_live_sessions(sessions, &dead);

    assert_eq!(
        filtered.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
        vec!["a1", "b2"],
        "exactly the active row and the stopped row the sweep left alone \
         survive; the slot tombstone is hidden too since #5952"
    );
}

#[test]
fn picker_filter_keeps_a_live_row_flagged_unresumable() {
    // 🔴 CRITICAL regression guard (PR #4995 review). `unresumable` is computed
    // daemon-side from a record's PERSISTED state; `reconcile_live_state` then
    // overwrites the DISPLAY state without recomputing it, so the wire really
    // does ship `state: "active"` with `unresumable: true` for a session whose
    // pane is running. Both halves of that are pinned daemon-side, in the order
    // `summary.rs` runs them: `checked_summaries_with` probes `unresumable` for
    // persisted-`Stopped`/`Errored` records only, then
    // `reconcile_live_state_flips_stopped_to_active_when_alive` shows the
    // display state flipping to `active` with no second probe.
    //
    // The sweep refuses to reap such a row
    // (`auto_prune_never_touches_a_running_record`,
    // `auto_prune_never_touches_an_errored_record_with_a_live_detached_pane`),
    // so hiding it would leave a running agent hidden AND unreapable.
    //
    // Both rows below carry `unresumable: true` and neither is in `dead_ids`.
    // A filter that consults the flag fails here; one that consults the sweep's
    // verdict passes.
    let sessions: Vec<trusty_mpm::client::ManagedSessionSummary> =
        serde_json::from_value(serde_json::json!([
            { "id": "a1", "name": "live-pane", "state": "active",  "unresumable": true },
            { "id": "b2", "name": "detached",  "state": "errored", "unresumable": true },
        ]))
        .expect("test data must deserialize");

    let filtered = filter_live_sessions(sessions, &std::collections::HashSet::new());

    assert_eq!(
        filtered.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
        vec!["a1", "b2"],
        "a row carrying the raw `unresumable` flag that the sweep did NOT \
         classify dead must stay visible — hiding it makes a live session \
         unreachable through `tm`"
    );
}

#[test]
fn scope_for_display_all_keeps_dead_record_visible() {
    // #4994 is a VISIBILITY change, never a deletion: `tm ls --all` must still
    // list every dead record, exactly as it already does for decommissioned
    // ones. This is the operator's path to see and act on them; the remedy verb
    // is `tm sessions delete <id> --force`, the same one
    // `unresumable_remedy_line` cites (there is no `tm sessions rm`).
    use crate::commands::session_picker::{parse_managed_sessions, scope_for_display};

    let raw = serde_json::json!({
        "sessions": [
            { "id": "a1", "name": "sess-active", "state": "active",  "slot": 1 },
            { "id": "c3", "name": "sess-dead",   "state": "stopped", "slot": 2 },
        ]
    })
    .to_string();
    let sessions = parse_managed_sessions(&raw).expect("parse must succeed");
    let dead: std::collections::HashSet<String> = ["c3".to_string()].into_iter().collect();

    assert_eq!(
        scope_for_display(sessions.clone(), true, &dead)
            .iter()
            .map(|s| s.id.as_str())
            .collect::<Vec<_>>(),
        vec!["a1", "c3"],
        "--all must keep the dead record"
    );
    assert_eq!(
        scope_for_display(sessions, false, &dead)
            .iter()
            .map(|s| s.id.as_str())
            .collect::<Vec<_>>(),
        vec!["a1"],
        "the default view must drop the same record --all keeps"
    );
}

#[test]
fn scope_for_display_hides_tombstone_by_default_and_all_shows_it() {
    // #5952 in one assertion pair, at the seam both `tm ls` and `tm sessions ls`
    // run through. The default view drops the `-- deleted --` slot rows; `--all`
    // lists every one of them, still at its original slot and still in slot
    // order (tombstones are excluded from the sink-to-bottom sort).
    //
    // 🔴 This is the #3034 guard, restated for the hidden case: slot 3 is
    // ABSENT from the default listing, never renumbered and never handed to
    // another session. The gap between slots 2 and 4 is what an operator sees.
    use crate::commands::session_picker::{parse_managed_sessions, scope_for_display};

    let raw = serde_json::json!({
        "sessions": [
            { "id": "a1", "name": "sess-one", "state": "active",  "slot": 2 },
            { "id": "",   "name": "",         "state": "deleted", "slot": 3, "deleted": true },
            { "id": "c3", "name": "sess-two", "state": "stopped", "slot": 4 },
        ]
    })
    .to_string();
    let sessions = parse_managed_sessions(&raw).expect("parse must succeed");
    let none: std::collections::HashSet<String> = std::collections::HashSet::new();

    assert_eq!(
        scope_for_display(sessions.clone(), false, &none)
            .iter()
            .map(|s| s.slot)
            .collect::<Vec<_>>(),
        vec![2, 4],
        "the default view must omit the deleted slot, leaving its number as a gap"
    );
    assert!(
        !scope_for_display(sessions.clone(), false, &none)
            .iter()
            .any(|s| s.deleted),
        "no tombstone row may reach the default table"
    );
    assert_eq!(
        scope_for_display(sessions, true, &none)
            .iter()
            .map(|s| (s.slot, s.deleted))
            .collect::<Vec<_>>(),
        vec![(2, false), (3, true), (4, false)],
        "--all must list the tombstone at its original slot, in slot order"
    );
}

// ── `--no-prune` on both listing surfaces (#5950) ────────────────────────────

/// Both `ls` surfaces accept `--no-prune`, and neither prunes-by-default flips.
///
/// Why (#5950): auto-pruning every listing is deliberate (#4702), but it makes
/// a read verb mutate — three records were decommissioned during a fleet census
/// that only asked for `--all --json`. The opt-out has to exist on the surface
/// the operator actually types, which is `tm ls` as often as `tm sessions ls`.
#[test]
fn cli_parses_no_prune_on_both_listing_surfaces() {
    let top = Cli::try_parse_from(["trusty-mpm", "ls", "--json", "--no-prune"]).unwrap();
    match top.command.unwrap() {
        Command::Ls { no_prune, json, .. } => {
            assert!(no_prune, "top-level `tm ls --no-prune` must parse to true");
            assert!(json);
        }
        other => panic!("expected top-level ls, got {other:?}"),
    }

    let sessions =
        Cli::try_parse_from(["trusty-mpm", "sessions", "ls", "--all", "--no-prune"]).unwrap();
    match sessions.command.unwrap() {
        Command::Sessions {
            action: SessionAction::Ls { no_prune, all, .. },
        } => {
            assert!(no_prune, "`tm sessions ls --no-prune` must parse to true");
            assert!(all);
        }
        other => panic!("expected sessions ls, got {other:?}"),
    }

    // Absent the flag, the #4702 default is unchanged on both surfaces.
    match Cli::try_parse_from(["trusty-mpm", "ls"])
        .unwrap()
        .command
        .unwrap()
    {
        Command::Ls { no_prune, .. } => assert!(!no_prune),
        other => panic!("expected top-level ls, got {other:?}"),
    }
    match Cli::try_parse_from(["trusty-mpm", "sessions", "ls"])
        .unwrap()
        .command
        .unwrap()
    {
        Command::Sessions {
            action: SessionAction::Ls { no_prune, .. },
        } => assert!(!no_prune),
        other => panic!("expected sessions ls, got {other:?}"),
    }
}

/// The `ls` help states that a plain listing prunes (#5950 closure condition).
///
/// Why: the reporter's objection was not that pruning happens but that nothing
/// at the call site said it would. A doc comment in `managed.rs` is not the
/// call site an operator sees; `--help` is.
#[test]
fn ls_help_states_that_a_plain_listing_prunes() {
    let render = |argv: &[&str]| {
        Cli::try_parse_from(argv)
            .expect_err("--help exits through clap's error channel")
            .to_string()
    };
    for argv in [
        ["trusty-mpm", "ls", "--help"].as_slice(),
        ["trusty-mpm", "sessions", "ls", "--help"].as_slice(),
    ] {
        let help = render(argv);
        assert!(
            help.contains("prune"),
            "`{argv:?}` help must tell the operator this listing prunes: {help}"
        );
    }
}

/// #6118: `--state unresolvable` parses, and needs no `--include-active`.
///
/// Why: `--state active` was rejected outright when an operator went looking for
/// this class, so the new spelling has to reach the daemon from the CLI — and it
/// has to do so without the blanket flag that also selects healthy sessions.
///
/// This test and its sibling below live HERE rather than beside the rest of the
/// `cli_parses_session_*` family: `tests_behavior_d_tests.rs` sits at the
/// 3000-SLOC test cap, and adding them there pushed it to 3013.
/// Test: this function IS the test.
#[test]
fn cli_parses_session_prune_unresolvable() {
    let cli = Cli::try_parse_from([
        "trusty-mpm",
        "session",
        "prune",
        "--state",
        "unresolvable",
        "--dry-run",
    ])
    .unwrap();
    match cli.command.unwrap() {
        Command::Session {
            action:
                SessionAction::Prune {
                    state,
                    dry_run,
                    include_active,
                },
        } => {
            assert_eq!(state, "unresolvable");
            assert!(dry_run);
            assert!(
                !include_active,
                "the unresolvable selector must not need the blanket flag"
            );
        }
        other => panic!("expected session prune, got {other:?}"),
    }
}

/// #6118: `decommission-ephemeral` accepts `--dry-run`.
///
/// Why: the verb had no preview flag at all, so an operator's only way to learn
/// what it would tear down was to tear it down.
/// Test: this function IS the test.
#[test]
fn cli_parses_session_decommission_ephemeral_dry_run() {
    let cli = Cli::try_parse_from([
        "trusty-mpm",
        "session",
        "decommission-ephemeral",
        "--dry-run",
    ])
    .unwrap();
    match cli.command.unwrap() {
        Command::Session {
            action: SessionAction::DecommissionEphemeral { dry_run },
        } => assert!(dry_run),
        other => panic!("expected session decommission-ephemeral, got {other:?}"),
    }
}