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
//! `tm serve --stdio` — MCP stdio bridge for the trusty-mpm daemon (#1221).
//!
//! Why: Claude Code launches MCP servers as stdio subprocesses, but the
//! trusty-mpm daemon must be a durable 24/7 process (it runs the supervisor, the
//! reaper, the file watcher, and the Telegram bot — a stdio child would die with
//! its client). So, exactly like `trusty-memory serve --stdio`, this is a thin
//! PROXY: it ensures the HTTP daemon is up, then forwards every JSON-RPC request
//! to the daemon's loopback `POST /rpc` and relays the response. It never holds
//! daemon state itself; it exits when Claude Code closes the pipe, leaving the
//! daemon running.
//!
//! What: [`run_stdio_bridge`] (1) ensures the daemon is reachable via the shared
//! `trusty_mcp::ensure_daemon_up` helper (auto-starting `tm daemon`
//! detached if absent, polling the lock file for the real dynamic port); (2)
//! enters `run_stdio_loop`, forwarding each non-notification request to
//! `POST /rpc` and returning the daemon's response verbatim. Transport errors
//! become a JSON-RPC internal error rather than crashing the loop, so the bridge
//! survives a daemon restart and the next request reconnects.
//!
//! STDOUT hygiene: NEVER write to stdout — it is the JSON-RPC channel. All
//! diagnostics go to stderr (via `eprintln!` inside the shared helper).
//!
//! #6888: the bridge also STAMPS the caller's own managed session id onto the
//! two caller-session tools when the caller omitted it — see
//! [`stamp_caller_session_id`]. It is the only process in the chain that can:
//! Claude Code spawned it inside the managed pane, while the daemon it forwards
//! to is long-lived and shared.
//!
//! Test: `is_notification_*`, `value_to_mcp_response_*`,
//! `forward_rpc_errors_on_refused`, and `stamp_*` unit tests below; the
//! daemon-side `POST /rpc` dispatch is covered by `daemon::api_tests::rpc_*`.

use std::time::Duration;

use anyhow::{Context, Result, anyhow};
use trusty_mcp as mcp;
use trusty_mcp::{DaemonBridgeConfig, Request, Response};

/// Per-request forwarding timeout (60 s — headroom for slow tmux/provision ops).
///
/// Why: a session spawn provisions a workspace (git clone) and a tmux host,
/// which can take many seconds; a generous ceiling prevents a slow-but-valid
/// call from being cut off while still capping a truly hung request.
/// Test: `forward_rpc_errors_on_refused` exercises the error path within bound.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);

/// Build the shared reqwest client used for every forwarded RPC call.
///
/// Why: one client enables HTTP keep-alive to the daemon, reducing latency.
/// What: a blocking-free async client with the request + connect timeout set.
/// Test: `build_rpc_client_succeeds`.
fn build_rpc_client() -> Result<reqwest::Client> {
    reqwest::Client::builder()
        .timeout(REQUEST_TIMEOUT)
        .connect_timeout(REQUEST_TIMEOUT)
        .build()
        .context("build reqwest client for trusty-mpm stdio bridge")
}

/// Build the `DaemonBridgeConfig` for the trusty-mpm stdio bridge.
///
/// Why: the daemon discovers its own (possibly ephemeral) port and records it in
/// `~/.trusty-mpm/daemon.lock`. `resolve_daemon_url(None)` re-reads that lock on
/// every call, so passing it as `base_url_fn` lets `ensure_daemon_up` track a
/// dynamic port as soon as the daemon writes the lock.
///
/// `no_spawn` is now launchd-aware (issue #2486, precedent: trusty-memory's
/// #1152 fix). The doc comment this replaced claimed singleton-guarding made
/// unconditional auto-spawn "safe" — #2486 disproved that: during the
/// documented `launchctl bootout → cargo install → launchctl bootstrap`
/// restart, this bridge's auto-reconnect can race the restart and spawn an
/// ORPHAN daemon (PPID pointing at the client chain, not launchd) *before*
/// launchd relaunches the real one. The orphan answers `/health` 200 — so the
/// old "singleton-guarded" reasoning silently masked the bug — but it lacks
/// the plist's `EnvironmentVariables` (`TELEGRAM_BOT_TOKEN`,
/// `OPENROUTER_API_KEY`) and runs with `cwd=$HOME`, so features like the
/// Telegram poller never start. When a trusty-mpm launchd unit IS registered,
/// `launchctl bootstrap`/`kickstart` is the correct way to bring the daemon
/// back, so this bridge must refuse to auto-spawn and instead surface a clear
/// error (`ensure_daemon_up`'s `no_spawn` path) — the console's existing
/// backoff/retry recovers once launchd finishes the restart. On a dev machine
/// with no registered launchd unit, auto-spawn remains the convenient default,
/// unchanged from `tm start`'s existing behaviour.
/// What: returns a config that probes `/health`, resolves the base URL from
/// the lock file each poll, sets `no_spawn` to
/// [`crate::commands::launchd_probe::compute_no_spawn`] applied to
/// [`crate::commands::launchd_probe::launchd_may_own_daemon`], and sets a
/// trusty-mpm-specific `no_spawn_hint` (issue #2491) — the shared helper's
/// generic hint names a `{service} setup` subcommand and an
/// `io.trusty.{service}.plist` path that do not exist for trusty-mpm (there is
/// no `trusty-mpm setup` subcommand; the real plists are `com.trusty.mpm.plist`
/// / `com.trusty.mpm.supervisor.plist`).
/// Test: `build_bridge_config_no_spawn_matches_plist_probe` and
/// `build_bridge_config_no_spawn_hint_names_real_plists` below;
/// `ensure_daemon_up`'s no-spawn error path is unit-tested in
/// `trusty_mcp::daemon_bridge` (`no_spawn_returns_err_without_spawning`,
/// `no_spawn_error_uses_hint_when_set`).
fn build_bridge_config() -> DaemonBridgeConfig {
    build_bridge_config_for(trusty_mpm::core::isolated_environment())
}

/// [`build_bridge_config`] with the sandbox check injected.
///
/// Why (#9556): the bridge auto-spawns a plain `tm daemon` when nothing
/// answers, which in a sandbox binds the host default address.
/// What: `isolated` forces `no_spawn` and swaps the hint for the
/// `DaemonUrlError::NoSandboxDaemon` message.
/// Test: `build_bridge_config_never_spawns_under_isolation`.
fn build_bridge_config_for(isolated: bool) -> DaemonBridgeConfig {
    let no_spawn = isolated
        || crate::commands::launchd_probe::compute_no_spawn(
            crate::commands::launchd_probe::launchd_may_own_daemon(),
        );
    let no_spawn_hint = if isolated {
        trusty_mpm::core::DaemonUrlError::NoSandboxDaemon.to_string()
    } else {
        mpm_no_spawn_hint()
    };
    DaemonBridgeConfig {
        service_name: "trusty-mpm".to_string(),
        // The daemon picks its own port and records it in the lock file; no addr
        // arg is passed (matches `tm start`).
        spawn_args: vec!["daemon".to_string()],
        health_path: "/health".to_string(),
        // INTENTIONALLY kept on the sync direct-lock resolver (#1849 Phase 2):
        // the stdio bridge calls this closure on every reconnect to track the
        // daemon's current port. The gateway resolver is async and requires a
        // reqwest::Client; the `base_url_fn` signature is synchronous. Routing
        // the MCP bridge through the gateway would also add latency on every
        // JSON-RPC call and create a dependency on the console being up — an
        // unacceptable regression for the core MCP path.
        base_url_fn: Box::new(|| trusty_mpm::core::resolve_daemon_url(None)),
        startup_timeout: None, // shared 30s default
        poll_interval: None,   // shared 500ms default
        no_spawn,
        no_spawn_hint: Some(no_spawn_hint),
    }
}

/// Build trusty-mpm's operator guidance for the `no_spawn` error (issue #2491).
///
/// Why: the shared `DaemonBridgeConfig`'s generic `no_spawn` hint templates
/// `{service_name} setup` and `~/Library/LaunchAgents/io.trusty.{service_name}.plist`
/// — for trusty-mpm that yields a nonexistent `trusty-mpm setup` subcommand and
/// a wrong plist path. This function returns the correct, trusty-mpm-specific
/// text instead: the real plist names and the `launchctl bootstrap`/`kickstart`
/// commands that actually restart it, plus a pointer back to the #2486 restart
/// race this `no_spawn` guard exists to avoid.
/// What: a fixed, service-specific string passed as
/// `DaemonBridgeConfig::no_spawn_hint`.
/// Test: `build_bridge_config_no_spawn_hint_names_real_plists`.
fn mpm_no_spawn_hint() -> String {
    // #4868: the plist name and the kickstart target are the same label, so
    // they come from the same constant. This hint's whole purpose is naming a
    // real unit — #2827 was this string shape with the wrong plist in it.
    let label = trusty_common::launchd_labels::MPM;
    format!(
        "trusty-mpm daemon is launchd-managed on this machine — start it with \
         `launchctl bootstrap gui/$UID ~/Library/LaunchAgents/{label}.plist` \
         (or kickstart: `launchctl kickstart -k gui/$UID/{label}`); this \
         bridge will not auto-spawn to avoid the #2486 restart race."
    )
}

/// Returns true if the request is a JSON-RPC notification.
///
/// Why: per the MCP spec (section 4.1) the server MUST NOT reply to a
/// notification. Suppression is decided from the REQUEST before forwarding —
/// forwarding a notification would make the daemon emit a response that the
/// bridge would write to stdout, corrupting the MCP channel.
/// What: returns true when `req.id` is absent OR the method begins with
/// `"notifications/"`.
/// Test: `is_notification_detects_missing_id_and_prefix`.
fn is_notification(req: &Request) -> bool {
    req.id.is_none() || req.method.starts_with("notifications/")
}

/// Convert an `mcp::Request` into the JSON value POSTed to the daemon.
///
/// Why: `forward_rpc` sends raw JSON; the request envelope must be serialised.
/// What: `serde_json::to_value`, falling back to an empty object (which the
/// daemon rejects with a parse error — the correct behaviour) on the impossible
/// serialisation failure.
/// Test: covered transitively by the forwarding path.
fn req_to_value(req: &Request) -> serde_json::Value {
    serde_json::to_value(req).unwrap_or_else(|_| serde_json::json!({}))
}

/// Tools whose `session_id` argument names the CALLING session.
///
/// Why: only these two operate on the caller's own snapshot state, so only they
/// may have the bridge's managed session id stamped in. Every other tool's
/// `session_id` names a managed SUB-session the caller is acting on, and
/// stamping the caller's own id there would retarget the call (#6888).
const CALLER_SESSION_TOOLS: [&str; 2] = ["session_context_catchup", "session_context_pause"];

/// Fill in the caller's managed session id on a `tools/call` that omitted it.
///
/// Why: #6888 — the pause writer and the catch-up reader have to key the same
/// string, and only THIS process can supply the managed one. The daemon behind
/// the bridge is long-lived and shared, so its own environment names whatever
/// session happened to start it, not the caller; the bridge is the process
/// Claude Code spawned inside the managed pane, so its
/// `TM_MANAGED_SESSION_ID` is the caller's by construction. Without this the
/// daemon can only fall back to the tmux window, and a managed session that
/// moves windows loses its own history.
/// What: mutates `req` in place, and ONLY when every condition holds — the
/// method is `tools/call`, the tool is one of [`CALLER_SESSION_TOOLS`], the
/// arguments object carries no `session_id`, and this process really does export
/// a non-empty [`MANAGED_SESSION_ID_ENV`](trusty_common::catchup::session_id::MANAGED_SESSION_ID_ENV).
/// An explicit `session_id` is never overwritten.
/// Test: `stamp_fills_a_missing_session_id`,
/// `stamp_leaves_an_explicit_session_id_alone`,
/// `stamp_ignores_unrelated_tools`.
fn stamp_caller_session_id(req: &mut serde_json::Value, managed: Option<&str>) {
    let Some(managed) = managed.filter(|m| !m.is_empty()) else {
        return;
    };
    if req.get("method").and_then(serde_json::Value::as_str) != Some("tools/call") {
        return;
    }
    let Some(params) = req.get_mut("params") else {
        return;
    };
    let name = params.get("name").and_then(serde_json::Value::as_str);
    if !name.is_some_and(|n| CALLER_SESSION_TOOLS.contains(&n)) {
        return;
    }
    let Some(args) = params.get_mut("arguments").and_then(|a| a.as_object_mut()) else {
        return;
    };
    if args.contains_key("session_id") {
        return;
    }
    args.insert(
        "session_id".to_string(),
        serde_json::Value::String(managed.to_string()),
    );
}

/// Convert the daemon's JSON-RPC response value into an `mcp::Response`.
///
/// Why: `run_stdio_loop` expects `mcp::Response`; the daemon returns a raw value
/// in the standard `{jsonrpc, id, result | error}` shape.
/// What: extracts `id`, returns `ok` when `result` is present, `err` when `error`
/// is present, or an internal error when the response is malformed.
/// Test: `value_to_mcp_response_maps_ok_err_and_malformed`.
fn value_to_mcp_response(v: serde_json::Value) -> Response {
    let id = v.get("id").cloned().filter(|id| !id.is_null());

    if let Some(result) = v.get("result").cloned() {
        return Response::ok(id, result);
    }
    if let Some(err) = v.get("error") {
        let code = err
            .get("code")
            .and_then(|c| c.as_i64())
            .map(|c| c as i32)
            .unwrap_or(mcp::error_codes::INTERNAL_ERROR);
        let message = err
            .get("message")
            .and_then(|m| m.as_str())
            .unwrap_or("unknown daemon error")
            .to_string();
        return Response::err(id, code, message);
    }
    Response::err(
        id,
        mcp::error_codes::INTERNAL_ERROR,
        "daemon returned a response with neither result nor error",
    )
}

/// POST one JSON-RPC request to `{base_url}/rpc` and return the response body.
///
/// Why: the core forwarding primitive — returns the daemon's response verbatim
/// so MCP clients see real tool output, not a bridge-generated stand-in.
/// What: serialises `req`, POSTs to `/rpc`, deserialises the JSON body.
/// Transport errors (refused, timeout) and non-2xx statuses become `Err`.
/// Test: `forward_rpc_errors_on_refused`.
async fn forward_rpc(
    client: &reqwest::Client,
    base_url: &str,
    req: serde_json::Value,
) -> Result<serde_json::Value> {
    let url = format!("{base_url}/rpc");
    let resp = client
        .post(&url)
        .json(&req)
        .send()
        .await
        .with_context(|| format!("POST {url}: connection to trusty-mpm daemon failed"))?;

    if !resp.status().is_success() {
        let status = resp.status();
        let body = resp.text().await.unwrap_or_default();
        return Err(anyhow!(
            "daemon returned HTTP {status} for POST /rpc: {body}"
        ));
    }

    resp.json::<serde_json::Value>()
        .await
        .context("deserialise JSON-RPC response from trusty-mpm daemon")
}

/// Run the trusty-mpm MCP stdio bridge.
///
/// Why: top-level entry point for `tm serve --stdio` (#1221). Mirrors the proven
/// `trusty-memory serve --stdio` pattern so the three daemons share one mental
/// model and the driver skill can call typed MCP tools instead of scraping CLI
/// output.
/// What: (1) ensures the daemon is up (auto-start + dynamic-port discovery via
/// the lock file); (2) builds a shared HTTP client; (3) enters `run_stdio_loop`,
/// suppressing notifications and forwarding every other request to `POST /rpc`,
/// re-resolving the daemon URL each request so a daemon restart on a new port is
/// picked up. A transport error returns a JSON-RPC internal error rather than
/// crashing the loop — the next request reconnects.
/// Test: the helpers are unit-tested below; the end-to-end forward path is
/// covered by the daemon's `rpc_*` integration tests.
pub(crate) async fn run_stdio_bridge() -> Result<()> {
    // Step 1: ensure the daemon is reachable (auto-start if needed). Hard error
    // on failure — no silent fallback. All output goes to stderr.
    let config = build_bridge_config();
    let _ = mcp::ensure_daemon_up(&config).await?;

    // Step 2: shared HTTP client.
    let client = build_rpc_client()?;

    // #6888: this process, not the daemon, is the one Claude Code spawned inside
    // the managed pane, so it is the only place the caller's own managed session
    // id can be read. Snapshot it once — the pane's environment cannot change
    // under a running bridge.
    let managed_session_id = trusty_common::catchup::session_id::managed_session_id_from_env();

    // Step 3: forward loop. Re-resolve the base URL per request so a restarted
    // daemon (possibly on a new ephemeral port) is followed automatically.
    mcp::run_stdio_loop(move |req| {
        let client = client.clone();
        let managed_session_id = managed_session_id.clone();
        async move {
            if is_notification(&req) {
                return Response::suppressed();
            }
            let base_url = trusty_mpm::core::resolve_daemon_url(None);
            let mut req_value = req_to_value(&req);
            stamp_caller_session_id(&mut req_value, managed_session_id.as_deref());
            match forward_rpc(&client, &base_url, req_value).await {
                Ok(resp_value) => value_to_mcp_response(resp_value),
                Err(e) => {
                    tracing::warn!("trusty-mpm stdio bridge: transport error: {e:#}");
                    Response::err(
                        req.id.clone(),
                        mcp::error_codes::INTERNAL_ERROR,
                        format!("trusty-mpm daemon unreachable: {e:#}"),
                    )
                }
            }
        }
    })
    .await
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn build_rpc_client_succeeds() {
        assert!(build_rpc_client().is_ok());
    }

    /// This bridge's timeout must stay clear of `disk_survey`'s budget clamp.
    ///
    /// Why the two numbers need a test between them (#7313): they live in
    /// different modules and neither reads the other, but they are one
    /// contract — a survey allowed to run for longer than this bridge will wait
    /// produces a transport error instead of an answer, which is exactly what
    /// `budget_seconds: 120` did. Dropping `REQUEST_TIMEOUT` below the clamp
    /// plus headroom reintroduces that silently; this fails instead.
    #[test]
    fn the_forwarding_timeout_leaves_headroom_over_the_disk_survey_budget_clamp() {
        let clamp = Duration::from_secs(trusty_mpm::daemon::mcp_disk::MAX_BUDGET_SECONDS);
        let headroom = Duration::from_secs(5);
        assert!(
            REQUEST_TIMEOUT >= clamp + headroom,
            "a {clamp:?} survey plus {headroom:?} of serialization and round trip \
             does not fit in a {REQUEST_TIMEOUT:?} forwarding timeout — either \
             raise this timeout or lower `mcp_disk::MAX_BUDGET_SECONDS`"
        );
    }

    /// Why (#2486): `build_bridge_config`'s `no_spawn` must track whether a
    /// trusty-mpm launchd unit is registered on this host — this test compares
    /// against `launchd_may_own_daemon()` directly (not via
    /// `compute_no_spawn`, since `compute_no_spawn` is currently the identity
    /// function over that boolean) so the assertion pins the actual invariant
    /// rather than restating `build_bridge_config`'s own call chain. The pure
    /// decision table itself is covered by `commands::launchd_probe::tests`.
    #[test]
    fn build_bridge_config_no_spawn_matches_plist_probe() {
        let plist_exists = crate::commands::launchd_probe::launchd_may_own_daemon();
        let cfg = build_bridge_config();
        assert_eq!(
            cfg.no_spawn, plist_exists,
            "no_spawn must track the live plist probe"
        );
    }

    /// Why (#9556): a sandboxed bridge must fail closed with the sandbox
    /// message instead of spawning on the host default.
    /// Test: itself.
    #[test]
    fn build_bridge_config_never_spawns_under_isolation() {
        let cfg = build_bridge_config_for(true);
        assert!(cfg.no_spawn, "a sandboxed bridge never spawns a daemon");
        let hint = cfg.no_spawn_hint.unwrap_or_default();
        assert!(
            hint.contains("no daemon reachable for this sandbox"),
            "{hint}"
        );
    }

    /// Why (#2491 review): the shared helper's generic `no_spawn` hint names a
    /// `{service} setup` subcommand and an `io.trusty.{service}.plist` path
    /// that do not exist for trusty-mpm. `build_bridge_config` must override
    /// that with the real plist names and a working `launchctl` recipe.
    /// What: asserts the built config's `no_spawn_hint` contains both real
    /// plist basenames and does NOT contain the wrong generic wording.
    /// Test: this test.
    #[test]
    fn build_bridge_config_no_spawn_hint_names_real_plists() {
        let cfg = build_bridge_config();
        let hint = cfg
            .no_spawn_hint
            .expect("trusty-mpm must set a no_spawn_hint");
        assert!(
            hint.contains("com.trusty.mpm.plist"),
            "hint must name the real plist; got: {hint}"
        );
        assert!(
            hint.contains("launchctl bootstrap") || hint.contains("launchctl kickstart"),
            "hint must give a working launchctl recipe; got: {hint}"
        );
        assert!(
            !hint.contains("trusty-mpm setup"),
            "hint must NOT reference the nonexistent `trusty-mpm setup` subcommand; got: {hint}"
        );
        assert!(
            !hint.contains("io.trusty."),
            "hint must NOT reference the wrong io.trusty.* plist path; got: {hint}"
        );
    }

    #[test]
    fn is_notification_detects_missing_id_and_prefix() {
        let normal = Request {
            jsonrpc: Some("2.0".into()),
            id: Some(json!(1)),
            method: "tools/list".into(),
            params: None,
        };
        assert!(!is_notification(&normal));

        let no_id = Request {
            jsonrpc: Some("2.0".into()),
            id: None,
            method: "tools/list".into(),
            params: None,
        };
        assert!(is_notification(&no_id));

        let prefixed = Request {
            jsonrpc: Some("2.0".into()),
            id: Some(json!(9)),
            method: "notifications/initialized".into(),
            params: None,
        };
        assert!(is_notification(&prefixed));
    }

    #[test]
    fn value_to_mcp_response_maps_ok_err_and_malformed() {
        let ok = value_to_mcp_response(json!({"jsonrpc":"2.0","id":1,"result":{"tools":[]}}));
        assert!(ok.error.is_none());
        assert_eq!(ok.id, Some(json!(1)));

        let err = value_to_mcp_response(
            json!({"jsonrpc":"2.0","id":2,"error":{"code":-32601,"message":"x"}}),
        );
        assert_eq!(err.error.unwrap().code, -32601);

        let bad = value_to_mcp_response(json!({"jsonrpc":"2.0","id":3}));
        assert_eq!(bad.error.unwrap().code, mcp::error_codes::INTERNAL_ERROR);

        let null_id = value_to_mcp_response(json!({"jsonrpc":"2.0","id":null,"result":{}}));
        assert_eq!(null_id.id, None);
    }

    #[test]
    fn req_to_value_round_trips_method() {
        let req = Request {
            jsonrpc: Some("2.0".into()),
            id: Some(json!(5)),
            method: "session_list".into(),
            params: None,
        };
        let v = req_to_value(&req);
        assert_eq!(v["method"], "session_list");
        assert_eq!(v["id"], 5);
    }

    #[tokio::test]
    async fn forward_rpc_errors_on_refused() {
        let client = build_rpc_client().expect("client");
        let result = forward_rpc(&client, "http://127.0.0.1:65534", json!({"method":"ping"})).await;
        assert!(result.is_err(), "must error when no daemon is listening");
    }

    fn call(tool: &str, args: serde_json::Value) -> serde_json::Value {
        json!({
            "jsonrpc": "2.0",
            "id": 1,
            "method": "tools/call",
            "params": { "name": tool, "arguments": args },
        })
    }

    /// Why: #6888 — the daemon cannot read the caller's `TM_MANAGED_SESSION_ID`;
    /// this process can, and stamping it is what lets a managed session's pause
    /// and its next catch-up key the same string.
    /// What: both caller-session tools get the id filled in when they omit it.
    /// Test: itself.
    #[test]
    fn stamp_fills_a_missing_session_id() {
        for tool in CALLER_SESSION_TOOLS {
            let mut req = call(tool, json!({ "project_dir": "/tmp/p" }));
            stamp_caller_session_id(&mut req, Some("mgr-1"));
            assert_eq!(
                req["params"]["arguments"]["session_id"], "mgr-1",
                "{tool} must carry the caller's managed id"
            );
        }
    }

    /// Why: #6888 keeps the explicit override working — naming another
    /// session's id is the deliberate opt-in #5272 defined, and the bridge must
    /// never silently retarget it.
    /// What: an argument object that already has `session_id` is untouched, and
    /// an empty environment stamps nothing.
    /// Test: itself.
    #[test]
    fn stamp_leaves_an_explicit_session_id_alone() {
        let mut req = call(
            "session_context_pause",
            json!({ "project_dir": "/tmp/p", "session_id": "explicit" }),
        );
        stamp_caller_session_id(&mut req, Some("mgr-1"));
        assert_eq!(req["params"]["arguments"]["session_id"], "explicit");

        let mut unmanaged = call("session_context_pause", json!({ "project_dir": "/tmp/p" }));
        stamp_caller_session_id(&mut unmanaged, None);
        assert!(unmanaged["params"]["arguments"]["session_id"].is_null());
        stamp_caller_session_id(&mut unmanaged, Some(""));
        assert!(unmanaged["params"]["arguments"]["session_id"].is_null());
    }

    /// Why: every OTHER tool's `session_id` names a managed SUB-session, so
    /// stamping the caller's own id there would retarget the call — a stop on a
    /// sibling session becomes a stop on yourself.
    /// What: an unrelated tool, and a non-`tools/call` method, are both left
    /// exactly as they arrived.
    /// Test: itself.
    #[test]
    fn stamp_ignores_unrelated_tools() {
        let mut req = call("session_stop", json!({}));
        stamp_caller_session_id(&mut req, Some("mgr-1"));
        assert!(req["params"]["arguments"]["session_id"].is_null());

        let mut ping = json!({"jsonrpc": "2.0", "id": 2, "method": "ping"});
        let before = ping.clone();
        stamp_caller_session_id(&mut ping, Some("mgr-1"));
        assert_eq!(ping, before);
    }
}