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
//! Integration tests for [`super::TmConnector`] (DOC-44 twin Phase 1, issue
//! #3007 test-layer (b)).
//!
//! Why: drives the connector against a REAL in-process daemon HTTP surface
//! (a real axum router bound to a random loopback port) rather than mocking
//! `reqwest` — the whole point of this connector is the wire contract with
//! the daemon's actual routes, so a mock would only prove the mock is
//! self-consistent. Mirrors `crates/trusty-mpm/src/client/proxy/tests.rs`'s
//! `spawn_test_daemon` pattern exactly (its own doc calls this "the isolated-
//! managed helper", duplicated here rather than shared because it is a
//! four-line, module-private test helper — not worth a cross-module `pub`
//! surface for).
//! What: session-lifecycle-independent tests (unknown-id error mapping,
//! wrong-`BackendParams` client-side rejection) run first, needing no git
//! binary. [`create_session_full_lifecycle`] is the one end-to-end test that
//! exercises the REAL daemon spawn path — the handler always uses real git
//! (`crates/trusty-mpm/src/daemon/managed_routes/lifecycle.rs:564`), so a
//! hermetic spawn test needs a real (but local-only, no network) git repo —
//! mirroring `tests/session_manager_mvp.rs`'s `live_provision_real_repo`
//! fixture, except NOT `#[ignore]`-gated: cloning `file://<local bare repo>`
//! needs only the `git` binary already required to develop this workspace,
//! never the network. `with_root_isolated_managed`'s `FakeNoopTmuxDriver`
//! fakes every tmux operation (create/send/capture/list), so no real tmux is
//! ever touched.
//! Test: this file IS the test module (`#[path = "tm_tests.rs"] mod tests;`
//! in `tm.rs`). The unknown-id/not-found and create->list->status->send_input
//! assertions run through [`ConnectorTestKit`] (shared with
//! `crates/trusty-code/tests/connector_e2e.rs`) rather than being hand-rolled
//! here — only `attach`'s tm-specific `ShellCommand` shape and `delegate`'s
//! tm-specific gate+record semantics (which the kit deliberately does not
//! model — see [`ConnectorTestKit::assert_delegate_not_supported`]'s docs,
//! written for tcode's `NotSupported` contract, not tm's actually-supported
//! one) stay hand-rolled.

use std::future::IntoFuture;
use std::process::Command;

use trusty_agents_common::connectors::{
    AgentSpec, BackendParams, ConnectorTestKit, CreateSessionReq,
};

use super::*;

/// RAII guard restoring `$HOME` on drop (including panic) — mirrors the
/// identical pattern in `core::session_launch::tests::EnvVarGuard` and
/// siblings (each module needs its own copy; `pub(super)`/module-private
/// visibility does not cross sibling module trees).
///
/// Why (#3450 follow-up, #3965): see the comment at its one call site in
/// [`create_session_full_lifecycle`].
struct HomeGuard(Option<String>);
impl Drop for HomeGuard {
    fn drop(&mut self) {
        // SAFETY: paired with `#[serial_test::serial]` — no other thread
        // reads/writes the environment concurrently.
        match self.0 {
            Some(ref p) => unsafe { std::env::set_var("HOME", p) },
            None => unsafe { std::env::remove_var("HOME") },
        }
    }
}

/// Point `$HOME` at `home` for the duration of the caller's scope. Callers
/// MUST be `#[serial_test::serial]` — see [`HomeGuard`].
fn set_home(home: &std::path::Path) -> HomeGuard {
    let prior = std::env::var("HOME").ok();
    // SAFETY: serialized via `#[serial_test::serial]`.
    unsafe { std::env::set_var("HOME", home) };
    HomeGuard(prior)
}

/// Spawn the daemon's real HTTP API on a random loopback port (empty fleet,
/// tmux faked). Mirrors `client::proxy::tests::spawn_test_daemon`, but —
/// unlike that helper — serves with `into_make_service_with_connect_info`
/// (matching `daemon::mod::run_http`'s real production wiring), because this
/// connector's `delegate` method calls the loopback-gated `POST /rpc` route,
/// which extracts `ConnectInfo<SocketAddr>` to enforce that gate (see
/// `daemon::api::rpc::rpc_handler`'s docs) — a plain `axum::serve(listener,
/// router)` never populates that extension and every `/rpc` call would 500.
///
/// Returns the root [`tempfile::TempDir`] guard alongside the URL — the
/// caller MUST hold it for the test's duration (#3450: the previous
/// `tempfile::tempdir().unwrap().keep()` both rooted under a possibly-polluted
/// `$TMPDIR` (see `crate::test_support`'s #3382 doc) AND permanently disabled
/// cleanup, so every test run left a fresh, un-reapable root behind).
async fn spawn_test_daemon() -> (tempfile::TempDir, String) {
    use crate::daemon::{api, state::DaemonState};
    let root_guard = crate::test_support::hermetic_temp_dir();
    let state = std::sync::Arc::new(
        DaemonState::with_root_isolated_managed(root_guard.path().to_owned()).await,
    );
    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_make_service_with_connect_info::<std::net::SocketAddr>(),
        )
        .into_future(),
    );
    (root_guard, format!("http://{addr}"))
}

/// Serve a stub daemon whose managed-session routes each sleep `delay` before
/// answering, on a random loopback port. Returns the base URL.
///
/// Why (#4488): the real [`create_session_full_lifecycle`] cannot pin WHICH
/// timeout bounds the provisioning POST — it just runs the real handler and
/// hopes the machine is fast enough, which is precisely the load-sensitivity
/// that made it flake. This stub inverts the control: the TEST owns the
/// server-side latency, so the bound under test can be exercised in
/// milliseconds instead of the 180s the production constant names, with no
/// wall-clock guess anywhere in the assertion.
/// What: `POST /api/v1/sessions/managed` sleeps then returns a minimal valid
/// [`ManagedSpawnResponse`]; `GET /api/v1/sessions/managed` sleeps then
/// returns an empty fleet. Both routes share `delay` so the two tests below
/// differ only in which one they call.
async fn spawn_slow_stub_daemon(delay: std::time::Duration) -> String {
    use axum::routing::post;

    let spawn_body = serde_json::json!({
        "id": "11111111-1111-1111-1111-111111111111",
        "name": "stub-session",
        "state": "active",
    });
    let router = axum::Router::new().route(
        "/api/v1/sessions/managed",
        post(move || async move {
            tokio::time::sleep(delay).await;
            axum::Json(spawn_body)
        })
        .get(move || async move {
            tokio::time::sleep(delay).await;
            axum::Json(serde_json::json!({ "sessions": [] }))
        }),
    );
    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}")
}

/// A `reqwest::Client` whose CLIENT-LEVEL request timeout is `bound` — the
/// millisecond-scale stand-in for production's 10s `DEFAULT_REQUEST_TIMEOUT`.
fn client_bounded_at(bound: std::time::Duration) -> reqwest::Client {
    reqwest::Client::builder()
        .timeout(bound)
        .build()
        .expect("build bounded test client")
}

/// #4488 regression: `create_session` must survive a daemon that takes LONGER
/// than the client-level default request timeout to answer.
///
/// The daemon provisions synchronously inside this request (git clone +
/// worktree add + agent deploy + harness spawn). Before the fix the connector
/// let the 10s interactive `DEFAULT_REQUEST_TIMEOUT` bound that work, so
/// `create_session_full_lifecycle` — which measured 9.54s at load 15 — passed
/// only on an idle machine and failed under load with a `Transport` error,
/// while the daemon kept provisioning and orphaned the session.
///
/// Load-robust by construction: the client bound is 100ms and the stub answers
/// at 400ms, but the assertion is that the request SUCCEEDS, which the
/// 180s per-request provisioning override guarantees no matter how slow or
/// loaded the machine running this test is. Nothing here gets faster-or-fails.
#[tokio::test]
async fn create_session_outlives_the_default_request_timeout() {
    let url = spawn_slow_stub_daemon(std::time::Duration::from_millis(400)).await;
    let connector = TmConnector::with_client(
        client_bounded_at(std::time::Duration::from_millis(100)),
        url,
    );
    let req = CreateSessionReq {
        task: "provision me".into(),
        name_hint: None,
        agent: None,
        backend: BackendParams::Tm {
            repo_url: "file:///dev/null".into(),
            git_ref: "main".into(),
            runtime: None,
            ephemeral: true,
        },
    };
    let info = connector.create_session(req).await.expect(
        "create_session must apply the provisioning timeout override, not the \
         client-level default",
    );
    assert_eq!(info.id, "11111111-1111-1111-1111-111111111111");
}

/// Negative control for [`create_session_outlives_the_default_request_timeout`]:
/// the provisioning override must be SCOPED to the provisioning route. A point
/// read against the same slow stub, through the same connector, must still be
/// cut off by that connector's client-level bound.
///
/// What this catches, precisely (#4488 review, M3): a change that widens the
/// override from one route to the whole connector — a `RequestBuilder::timeout`
/// moved onto a shared helper, or the client itself rebuilt with the
/// provisioning bound. Under any of those the sibling test still passes while
/// every point read silently inherits a 180s ceiling, so this is the only
/// assertion in the pair that would notice.
///
/// What it does NOT catch, despite an earlier version of this comment claiming
/// otherwise: removal of the timeout from the PRODUCTION client built by
/// `http_client::config::build_client`. Both tests here construct their own
/// client via [`client_bounded_at`] — that is what makes them run in
/// milliseconds instead of minutes — so neither one observes the production
/// default at all. `config::tests::build_client_bounds_a_stalled_connection`
/// and `config::tests::default_client_uses_default_bounds` are what guard the
/// #2471 regression; this test is not a second line of defence for it.
#[tokio::test]
async fn list_sessions_is_bound_by_the_client_level_timeout() {
    let url = spawn_slow_stub_daemon(std::time::Duration::from_millis(400)).await;
    let connector = TmConnector::with_client(
        client_bounded_at(std::time::Duration::from_millis(100)),
        url,
    );
    let err = connector
        .list_sessions()
        .await
        .expect_err("a 400ms response must not beat a 100ms client-level bound");
    // `decode_err` also produces `Transport`, so the variant alone cannot tell
    // a timeout from a malformed body (#4488 review, LOW). Rule the decode
    // path out by its literal prefix: reaching it would mean the stub answered
    // WITHIN the bound and the assertion above proved nothing about timeouts.
    match err {
        ConnectorError::Transport(ref msg) => assert!(
            !msg.starts_with("failed to decode daemon response"),
            "expected a transport-level timeout, got a decode failure: {msg}"
        ),
        other => panic!("expected ConnectorError::Transport, got {other:?}"),
    }
}

/// The env var `inproject::repos_root` reads first (#1807), duplicated here as
/// a literal so this test file depends on no private module.
const REPOS_ROOT_ENV: &str = "TRUSTY_MPM_REPOS_ROOT";

/// Build the managed checkout a session can run in, offline.
///
/// Why (#6000 / ADR-0055): the daemon no longer clones anything for a session,
/// so [`create_session_full_lifecycle`] must hand it a local checkout that
/// already exists. Placing that checkout at exactly
/// `<repos-root>/<owner>/<repo>` makes `managed_checkout::resolve_placement`
/// return it unchanged, so no base clone is attempted and the test still
/// touches no network.
/// What: creates `<root>/<owner>/<repo>`, initialises it with one commit on
/// `main` and a GitHub `origin` remote (never fetched — only parsed for the
/// owner/repo identity), and returns its path.
/// Test: used by `create_session_full_lifecycle`.
fn managed_checkout_in(root: &std::path::Path, owner: &str, repo: &str) -> String {
    let dir = root.join(owner).join(repo);
    std::fs::create_dir_all(&dir).expect("managed checkout dir");
    let origin = format!("https://github.com/{owner}/{repo}");
    let setup: Vec<Vec<&str>> = vec![
        vec!["init", "-q", "-b", "main"],
        vec!["config", "user.email", "connector-test@example.com"],
        vec!["config", "user.name", "connector-test"],
        vec!["remote", "add", "origin", &origin],
    ];
    for args in setup {
        let out = Command::new("git")
            .args(&args)
            .current_dir(&dir)
            .output()
            .expect("git");
        assert!(out.status.success(), "git {args:?} must succeed");
    }
    std::fs::write(dir.join("README.md"), "seed").expect("write seed file");
    for args in [vec!["add", "."], vec!["commit", "-q", "-m", "seed"]] {
        let out = Command::new("git")
            .args(&args)
            .current_dir(&dir)
            .output()
            .expect("git");
        assert!(out.status.success(), "git {args:?} must succeed");
    }
    dir.to_string_lossy().into_owned()
}

/// `CreateSessionReq::backend` carrying `BackendParams::Tcode` must be
/// rejected client-side, before any HTTP call — a caller bug, not a daemon
/// round-trip.
#[tokio::test]
async fn create_session_wrong_backend_params_is_invalid_request() {
    let connector = TmConnector::with_daemon_url("http://127.0.0.1:0");
    let req = CreateSessionReq {
        task: "irrelevant".into(),
        name_hint: None,
        agent: None,
        backend: BackendParams::Tcode {
            project: std::path::PathBuf::from("/tmp/proj"),
        },
    };
    let err = connector.create_session(req).await.unwrap_err();
    assert!(
        matches!(err, ConnectorError::InvalidRequest(_)),
        "expected InvalidRequest, got {err:?}"
    );
}

#[tokio::test]
async fn list_sessions_empty_fleet_returns_empty_vec() {
    let (_daemon_root, url) = spawn_test_daemon().await;
    let connector = TmConnector::with_daemon_url(url);
    let sessions = connector.list_sessions().await.expect("list_sessions");
    assert!(sessions.is_empty(), "fresh daemon must have no sessions");
}

/// Shared conformance assertion — see [`ConnectorTestKit::assert_status_not_found_for_unknown_id`].
#[tokio::test]
async fn session_status_unknown_id_is_not_found() {
    let (_daemon_root, url) = spawn_test_daemon().await;
    let connector = TmConnector::with_daemon_url(url);
    ConnectorTestKit::assert_status_not_found_for_unknown_id(
        &connector,
        "00000000-0000-0000-0000-000000000000",
    )
    .await;
}

/// Shared conformance assertion — see [`ConnectorTestKit::assert_send_not_found_for_unknown_id`].
#[tokio::test]
async fn send_input_unknown_id_is_not_found() {
    let (_daemon_root, url) = spawn_test_daemon().await;
    let connector = TmConnector::with_daemon_url(url);
    ConnectorTestKit::assert_send_not_found_for_unknown_id(
        &connector,
        "00000000-0000-0000-0000-000000000000",
    )
    .await;
}

#[tokio::test]
async fn attach_unknown_id_is_not_found() {
    let (_daemon_root, url) = spawn_test_daemon().await;
    let connector = TmConnector::with_daemon_url(url);
    let err = connector
        .attach("00000000-0000-0000-0000-000000000000")
        .await
        .unwrap_err();
    assert!(err.is_not_found(), "expected NotFound, got {err:?}");
}

/// `delegate` against an unknown session must surface as a `Backend` error
/// (the `agent_delegate` MCP tool's own "no such session" domain rejection,
/// carried through the `tools/call` `isError: true` convention — never a
/// panic or a malformed-envelope `Transport` error).
#[tokio::test]
async fn delegate_unknown_session_is_backend_error() {
    let (_daemon_root, url) = spawn_test_daemon().await;
    let connector = TmConnector::with_daemon_url(url);
    let spec = AgentSpec {
        agent_name: "research".into(),
        task: "find the bug".into(),
        tier: None,
    };
    let err = connector
        .delegate("00000000-0000-0000-0000-000000000000", &spec)
        .await
        .unwrap_err();
    assert!(
        matches!(err, ConnectorError::Backend(_)),
        "expected Backend error for an unknown session, got {err:?}"
    );
}

/// End-to-end: create -> list -> status -> send_input (via
/// [`ConnectorTestKit::assert_basic_lifecycle`]) -> attach -> delegate,
/// against a REAL daemon spawn (faked tmux via `FakeNoopTmuxDriver`).
///
/// #6000 / ADR-0055: the daemon clones nothing, so `repo_url` is the managed
/// checkout [`managed_checkout_in`] stands up under the same isolated root.
///
/// `#[serial_test::serial]` (#3450): this test mutates the process-global
/// `TRUSTY_MPM_WORKSPACE_ROOT` and `TRUSTY_MPM_REPOS_ROOT` env vars — see the
/// override comment below.
#[tokio::test]
#[serial_test::serial]
async fn create_session_full_lifecycle() {
    let (_daemon_root, url) = spawn_test_daemon().await;
    let connector = TmConnector::with_daemon_url(url.clone());

    // ── Hermetic workspace-provisioning root (#3450) ─────────────────────────
    // `DaemonState::with_root_isolated_managed` only isolates the session
    // manager's OWN metadata store (`root/session-manager`). The REAL
    // `spawn_managed` handler (`daemon/managed_routes/lifecycle.rs`)
    // independently calls `TrustyToolsConfig::load()` and resolves the
    // workspace-PROVISIONING root via `workspace_root(&config)`, which
    // defaults to the PRODUCTION `~/trusty-mpm-projects` whenever
    // `TRUSTY_MPM_WORKSPACE_ROOT` is unset — completely bypassing whatever
    // isolated root this test's `DaemonState` was built with. Compounding
    // this, `parse_github_path` treats ANY multi-segment URL (not just a
    // `github.com` one) as an owner/repo pair, so a `file://` URL used to
    // launder a scratch tempdir's OWN random name into "owner" — the
    // `~/trusty-mpm-projects/<tempdir-name>/origin/...` shape observed live in
    // #3450. Pointing both roots at the SAME hermetic `_daemon_root` this test
    // already uses closes that gap; `TRUSTY_MPM_REPOS_ROOT` is pinned too
    // because `managed_checkout_for` reads it FIRST (#6000 — the placement
    // resolver, not the removed provisioner, is what decides the path now).
    let prev_workspace_root =
        std::env::var(crate::core::trusty_tools_config::WORKSPACE_ROOT_ENV).ok();
    let prev_repos_root = std::env::var(REPOS_ROOT_ENV).ok();
    unsafe {
        std::env::set_var(
            crate::core::trusty_tools_config::WORKSPACE_ROOT_ENV,
            _daemon_root.path(),
        );
        std::env::set_var(REPOS_ROOT_ENV, _daemon_root.path());
    }

    // #6000 / ADR-0055: the checkout must EXIST before the spawn, and must sit
    // exactly where `resolve_placement` expects, so the placement resolver
    // returns it unchanged and no clone is attempted.
    let repo_url = managed_checkout_in(_daemon_root.path(), "an-owner", "a-repo");

    // #3965: the REAL spawn handler this test drives also
    // calls `session_launch::prepare_session_with_repo_url` /
    // `home_trust_seed::preseed_home_trust`, which seed `$HOME/.claude.json`
    // via the REAL process `$HOME` — a DIFFERENT resolution path from the
    // `TRUSTY_MPM_WORKSPACE_ROOT` override above, so pinning that alone still
    // let this test write real trust entries (keyed by the laundered
    // `.../origin/.base/...` workspace path, see the comment above) into the
    // operator's `~/.claude.json`. `#[serial]` (already present, for
    // `TRUSTY_MPM_WORKSPACE_ROOT`) + this override close that second gap.
    let _home_guard = set_home(_daemon_root.path());

    let req = CreateSessionReq {
        task: "list files".into(),
        name_hint: None,
        agent: None,
        backend: BackendParams::Tm {
            repo_url,
            git_ref: "main".into(),
            runtime: None,
            ephemeral: true,
        },
    };

    // NOT `ConnectorTestKit::assert_basic_lifecycle` (issue #3603 follow-up):
    // that helper's `send_input` step hard-asserts success, which assumes the
    // spawned session reaches `Active`. This test drives the REAL
    // spawn handler (module docs above), which calls the
    // REAL `ClaudeCodeAdapter::spawn` — unlike every other tmux operation
    // here, that is NOT faked by `FakeNoopTmuxDriver`, and it resolves an
    // actual `claude` binary on `PATH`/well-known dirs
    // (`ClaudeCodeAdapter::resolve_claude`). On a developer machine with
    // Claude Code installed this succeeds and the record reaches `Active`;
    // on a CI runner with no `claude` binary it fails and
    // the spawn handler calls `mark_errored`, leaving the record
    // `Errored`. Before #3603's send_input readiness gate, this test's
    // `assert_basic_lifecycle` call "passed" in BOTH cases only because
    // `send_input` had no Provisioning/Errored guard at all — on an Errored
    // session that meant silently typing `echo hello` + Enter into a bare
    // shell with no runtime attached, i.e. exactly the shell-execution risk
    // #3603 closes. That was the test asserting broken (vulnerable)
    // behavior, not a real contract. Assert the CORRECT, environment-
    // independent contract instead: `send_input` must succeed for `Active`,
    // and must be refused (never silently accepted) for anything else.
    let info = connector
        .create_session(req)
        .await
        .expect("create_session must succeed for a well-formed request");
    assert!(
        !info.id.is_empty(),
        "create_session must return a non-empty session id"
    );

    let listed = connector
        .list_sessions()
        .await
        .expect("list_sessions must succeed");
    assert!(
        listed.iter().any(|s| s.id == info.id),
        "list_sessions must include the just-created session {}",
        info.id
    );

    let status = connector
        .session_status(&info.id)
        .await
        .expect("session_status must succeed for a known id");
    assert_eq!(
        status.id, info.id,
        "session_status must echo back the id it was asked about"
    );

    // `session_status`'s `state` is DISPLAY-reconciled against live tmux
    // (#3302): with `FakeNoopTmuxDriver` the tmux session never "exists", so
    // an `active` PERSISTED record is shown as `stopped` here regardless of
    // what actually gates `send_input` (which reads the PERSISTED state —
    // `SessionManager::check_send_input_ready` via `self.get(id)`, never the
    // display-reconciled view). Fetch the raw summary directly to read
    // `persisted_state`, the field the daemon's own docs say resume/restart
    // (and, by the same logic, this test's expectation) must key off of,
    // rather than predicting the gate's outcome from a value it does not
    // actually consult.
    let raw: ManagedSessionSummary = reqwest::Client::new()
        .get(format!("{url}/api/v1/sessions/managed/{}", info.id))
        .send()
        .await
        .expect("raw status GET must succeed")
        .json()
        .await
        .expect("raw status GET must decode as ManagedSessionSummary");
    let persisted_state = raw.persisted_state.as_deref().unwrap_or(&raw.state);

    match persisted_state {
        "active" => {
            connector
                .send_input(&info.id, "echo hello")
                .await
                .expect("send_input must succeed for an Active session");
        }
        other => {
            let err = connector
                .send_input(&info.id, "echo hello")
                .await
                .expect_err(&format!(
                    "send_input must be refused for a non-Active persisted state \
                     ({other}), never silently accepted"
                ));
            assert!(
                matches!(err, ConnectorError::Backend(_)),
                "expected a Backend refusal for persisted state {other:?}, got {err:?}"
            );
        }
    }

    // Restore the env var immediately after the one call that provisions a
    // workspace — attach/delegate below never re-provision.
    unsafe {
        match prev_repos_root {
            Some(v) => std::env::set_var(REPOS_ROOT_ENV, v),
            None => std::env::remove_var(REPOS_ROOT_ENV),
        }
        match prev_workspace_root {
            Some(v) => std::env::set_var(crate::core::trusty_tools_config::WORKSPACE_ROOT_ENV, v),
            None => std::env::remove_var(crate::core::trusty_tools_config::WORKSPACE_ROOT_ENV),
        }
    }

    assert_eq!(
        info.task.as_deref(),
        Some("list files"),
        "create_session must echo back the task"
    );

    let attach = connector.attach(&info.id).await.expect("attach");
    match attach {
        AttachHandle::ShellCommand(cmd) => {
            assert!(
                cmd.contains("tmux attach"),
                "tm attach handle must be a tmux attach command, got {cmd:?}"
            );
        }
        other => panic!("tm connector must return ShellCommand, got {other:?}"),
    }

    let spec = AgentSpec {
        agent_name: "research".into(),
        task: "find the bug".into(),
        tier: Some("haiku".into()),
    };
    let handle = connector
        .delegate(&info.id, &spec)
        .await
        .expect("delegate must succeed against a live session");
    assert!(
        !handle.delegate_id.is_empty(),
        "delegate must return a non-empty delegate_id"
    );
}