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
//! Unit tests for `daemon::mcp_backend` — split out of `mcp_backend.rs`
//! (test-file budget: 1500 SLOC; the inline module pushed the production
//! file over its 500-SLOC cap once the two PM pause/resume context tools
//! (`session_context_catchup`/`session_context_pause`) grew it past the
//! frozen 566-SLOC budget).
//! What: exercises every `StateBackend` method against a freshly-built
//! `DaemonState` — session listing/status, agent delegation, memory
//! protection, circuit breaker status, hook events, and bug-reporting.
//! Test: this IS the test module.

use super::*;
use crate::core::session::{ControlModel, Session, SessionStatus};

fn state_with_session() -> (Arc<DaemonState>, SessionId) {
    let state = DaemonState::shared();
    let id = SessionId::new();
    let mut session = Session::new(id, "/tmp/p", ControlModel::Tmux, None);
    session.status = SessionStatus::Active;
    state.register_session(session);
    (state, id)
}

#[tokio::test]
async fn session_list_returns_registered_sessions() {
    // session_list now also reads the SessionManager store, so this test
    // must bind to an ISOLATED managed store (#1790) rather than the
    // production `~/.trusty-mpm` root that `DaemonState::shared` uses.
    let root = tempfile::TempDir::new().expect("root tempdir");
    let state = Arc::new(DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await);
    let id = SessionId::new();
    let mut session = Session::new(id, "/tmp/p", ControlModel::Tmux, None);
    session.status = SessionStatus::Active;
    state.register_session(session);

    let backend = StateBackend::new(state);
    let list = backend.session_list().await.unwrap();
    // One legacy session registered; the isolated managed store is empty.
    assert_eq!(list.as_array().unwrap().len(), 1);
    assert_eq!(list[0]["kind"], "legacy");
}

#[tokio::test]
async fn session_list_includes_managed_sessions() {
    // Regression for #1946: a session provisioned via the managed path
    // (`session_new` / `spawn_managed` / in-project worktree spawn) lives in
    // the SessionManager store, NOT the legacy `DaemonState` registry. The
    // sibling `session_stop` tool targets that store by id, so `session_list`
    // MUST surface managed sessions — otherwise the operator can never
    // discover the id needed to stop the session they are in.
    let root = tempfile::TempDir::new().expect("root tempdir");
    let state = Arc::new(DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await);

    // Seed one managed session directly in the store (fake tmux driver, so no
    // real tmux pane is created).
    let mgr = state.session_manager().await;
    let record = mgr
        .create(
            "fix the bug".to_string(),
            Some(root.path().to_path_buf()),
            Some("regression".to_string()),
            Some(root.path().to_path_buf()),
            None,
            None,
        )
        .await
        .expect("managed session create must succeed with the fake tmux driver");

    let backend = StateBackend::new(state);
    let list = backend.session_list().await.unwrap();
    let arr = list.as_array().expect("session_list returns an array");

    let found = arr.iter().any(|v| {
        v.get("id").and_then(|i| i.as_str()) == Some(record.id.to_string().as_str())
            && v.get("kind").and_then(|k| k.as_str()) == Some("managed")
    });
    assert!(
        found,
        "provisioned managed session {} must appear in session_list (got {list})",
        record.id
    );
}

#[tokio::test]
async fn session_status_unknown_id_errors() {
    let (state, _) = state_with_session();
    let backend = StateBackend::new(state);
    let err = backend.session_status("not-a-uuid").await.unwrap_err();
    assert!(err.contains("not a valid session id"));
}

#[tokio::test]
async fn session_status_resolves_managed_session() {
    // Regression for #1976: session_status previously only consulted the
    // legacy registry, so a managed (`tmpm-`) session — the primary type
    // trusty-mpm spawns — reported "no such session". It must now resolve via
    // the managed store fallback and report `kind: "managed"`.
    let root = tempfile::TempDir::new().expect("root tempdir");
    let state = Arc::new(DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await);
    let record = state
        .session_manager()
        .await
        .create(
            "fix the bug".to_string(),
            Some(root.path().to_path_buf()),
            None,
            Some(root.path().to_path_buf()),
            None,
            None,
        )
        .await
        .expect("managed create");

    let backend = StateBackend::new(state);
    let status = backend
        .session_status(&record.id.to_string())
        .await
        .expect("managed session must resolve via the #1976 fallback");
    assert_eq!(status["kind"], "managed");
    assert_eq!(status["session"]["id"], record.id.to_string());
    // #4141 negative case: this record has never been correlated to a Claude
    // session UUID (no `SessionStart` hook has landed), so the delegation
    // lookup cannot be bridged. Both `delegation_count` AND `delegations`
    // must report an explicit unknown — `null`, never `0`/`[]` — so an
    // unbridgeable lookup is distinguishable from "genuinely none in
    // flight" no matter which field a caller reads.
    assert_eq!(
        status["delegation_count"],
        Value::Null,
        "unbridged managed session must report delegation_count: null, not 0 (#4141)"
    );
    assert_eq!(
        status["delegations"],
        Value::Null,
        "unbridged managed session must report delegations: null, not [] (#4141)"
    );
    assert_eq!(status["delegation_lookup"], "unbridged");
}

#[tokio::test]
async fn session_status_bridges_managed_id_to_hook_observed_delegation() {
    // Regression for #4141: `session_status` resolved a `ManagedSessionId`
    // and passed it straight into `delegations_for`, but hook-observed
    // delegations are keyed by the Claude session UUID — a different
    // identifier space bridged only via `correlate_session_start` /
    // `SessionRecord::claude_session_id`. Without that bridge, this test's
    // delegation (registered under the Claude UUID, exactly as the real hook
    // pipeline in `daemon::api::observe` does) was invisible and
    // `delegation_count` silently reported 0 while a subagent was genuinely
    // in flight.
    let root = tempfile::TempDir::new().expect("root tempdir");
    let state = Arc::new(DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await);
    let mgr = state.session_manager().await;
    let record = mgr
        .create(
            "fix the bug".to_string(),
            Some(root.path().to_path_buf()),
            None,
            Some(root.path().to_path_buf()),
            None,
            None,
        )
        .await
        .expect("managed create");

    // Correlate the managed record to a Claude session UUID, exactly as
    // `correlate_session_start` does when the `SessionStart` hook fires.
    let claude_session_id = SessionId::new();
    mgr.set_claude_session_id(&record.id, &claude_session_id.0.to_string())
        .await
        .expect("set_claude_session_id");

    // Observe a delegation under the CLAUDE session UUID — the same key the
    // hook-observed tracker uses (see `daemon::api::observe`).
    state.upsert_delegation(Delegation::new(
        claude_session_id,
        None,
        "rust-engineer",
        ModelTier::Sonnet,
        "wire up the fix",
    ));

    let backend = StateBackend::new(state);
    let status = backend
        .session_status(&record.id.to_string())
        .await
        .expect("managed session must resolve");
    assert_eq!(status["kind"], "managed");
    assert_eq!(
        status["delegation_count"], 1,
        "delegation observed under the bridged Claude session UUID must be \
         visible via the managed id (#4141), got: {status}"
    );
    assert_eq!(status["delegation_lookup"], "ok");
}

#[tokio::test]
async fn session_status_union_keeps_managed_id_keyed_delegation_visible() {
    // Regression for the #4141 fix-round finding: the first version of this
    // fix REPLACED the managed-id lookup with the bridged Claude-UUID one
    // instead of unioning them, so an `agent_delegate`-keyed delegation (the
    // key `agent_delegate` uses for a managed session, certified by
    // `agent_delegate_accepts_managed_session` above and exercised for real
    // by `connectors::tm::WorkstreamConnector::delegate`) went invisible —
    // reported as `delegation_count: 0` under an affirmative
    // `delegation_lookup: "ok"`, even though the bridge itself was intact.
    // This test fails against that (reverted) shape and must pass here.
    let root = tempfile::TempDir::new().expect("root tempdir");
    let state = Arc::new(DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await);
    let mgr = state.session_manager().await;
    let record = mgr
        .create(
            "fix the bug".to_string(),
            Some(root.path().to_path_buf()),
            None,
            Some(root.path().to_path_buf()),
            None,
            None,
        )
        .await
        .expect("managed create");

    // Bridge succeeds (mirrors the critic's probe: correlate BEFORE the
    // managed-id delegation is registered).
    let claude_session_id = SessionId::new();
    mgr.set_claude_session_id(&record.id, &claude_session_id.0.to_string())
        .await
        .expect("set_claude_session_id");

    // Register a delegation keyed by the MANAGED id — exactly what
    // `agent_delegate` does for a managed session (see
    // `agent_delegate_accepts_managed_session`), NOT the Claude UUID.
    state.upsert_delegation(Delegation::new(
        SessionId(record.id.0),
        None,
        "rust-engineer",
        ModelTier::Sonnet,
        "wire up the fix",
    ));

    let backend = StateBackend::new(state);
    let status = backend
        .session_status(&record.id.to_string())
        .await
        .expect("managed session must resolve");
    assert_eq!(
        status["delegation_count"], 1,
        "an agent_delegate-keyed (managed-id) delegation must remain visible \
         after the bridge fix, not be dropped by it (#4141), got: {status}"
    );
    assert_eq!(status["delegation_lookup"], "ok");
}

#[tokio::test]
async fn agent_delegate_records_a_delegation() {
    let (state, id) = state_with_session();
    let backend = StateBackend::new(state.clone());
    let result = backend
        .agent_delegate(&id.0.to_string(), "research", "find the bug", Some("opus"))
        .await
        .unwrap();
    assert_eq!(result["agent"], "research");
    assert_eq!(state.delegations_for(id).len(), 1);
}

#[tokio::test]
async fn agent_delegate_refused_when_breaker_open() {
    let (state, id) = state_with_session();
    // Trip the breaker for `flaky` with three failures.
    for _ in 0..3 {
        state.record_outcome("flaky", false);
    }
    let backend = StateBackend::new(state);
    let err = backend
        .agent_delegate(&id.0.to_string(), "flaky", "task", None)
        .await
        .unwrap_err();
    assert!(err.contains("circuit breaker"));
}

#[tokio::test]
async fn agent_delegate_accepts_managed_session() {
    // Regression for #1976: delegation gating rejected managed (`tmpm-`)
    // sessions with "no such session" because it only checked the legacy
    // registry. A delegation from a managed session must now be accepted and
    // recorded (keyed by UUID).
    let root = tempfile::TempDir::new().expect("root tempdir");
    let state = Arc::new(DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await);
    let record = state
        .session_manager()
        .await
        .create(
            "implement the fix".to_string(),
            Some(root.path().to_path_buf()),
            None,
            Some(root.path().to_path_buf()),
            None,
            None,
        )
        .await
        .expect("managed create");

    let backend = StateBackend::new(state.clone());
    let result = backend
        .agent_delegate(
            &record.id.to_string(),
            "rust-engineer",
            "wire it up",
            Some("sonnet"),
        )
        .await
        .expect("delegation from a managed session must be accepted (#1976)");
    assert_eq!(result["agent"], "rust-engineer");
    // The delegation is keyed by the session UUID, shared across both families.
    assert_eq!(state.delegations_for(SessionId(record.id.0)).len(), 1);
}

#[tokio::test]
async fn memory_protect_classifies_pressure() {
    let (state, id) = state_with_session();
    let backend = StateBackend::new(state);
    let result = backend
        .memory_protect(&id.0.to_string(), 900, 1000)
        .await
        .unwrap();
    assert_eq!(result["pressure"], "Compact");
}

#[tokio::test]
async fn hook_event_rejects_unknown_event() {
    let (state, id) = state_with_session();
    let backend = StateBackend::new(state);
    let err = backend
        .hook_event(&id.0.to_string(), "NotAnEvent", Value::Null)
        .await
        .unwrap_err();
    assert!(err.contains("unknown hook event"));
}

#[tokio::test]
async fn hook_event_drives_circuit_breaker() {
    let (state, id) = state_with_session();
    let backend = StateBackend::new(state.clone());
    // Three subagent failures for `flaky` should trip its breaker.
    for _ in 0..3 {
        backend
            .hook_event(
                &id.0.to_string(),
                "SubagentStopFailure",
                json!({ "agent": "flaky" }),
            )
            .await
            .unwrap();
    }
    assert!(!state.breaker("flaky").allows_delegation());
}

#[tokio::test]
async fn hook_event_accepts_known_event() {
    let (state, id) = state_with_session();
    let backend = StateBackend::new(state.clone());
    backend
        .hook_event(&id.0.to_string(), "PreToolUse", json!({"tool": "Bash"}))
        .await
        .unwrap();
    assert_eq!(state.recent_hook_events().len(), 1);
}

// ── Phase 3: bug-reporting backend tests ──────────────────────────────────

#[tokio::test]
async fn list_recent_errors_returns_valid_json() {
    // The local daemon stores are typically empty in CI; this test verifies
    // the method returns a valid, parseable JSON object regardless.
    let (state, _) = state_with_session();
    let backend = StateBackend::new(state);
    let result = backend.list_recent_errors(20).await.unwrap();
    assert!(result["errors"].is_array(), "errors must be an array");
    assert!(result["limit"].is_number(), "limit must be a number");
}

#[tokio::test]
async fn preview_bug_report_unknown_fingerprint_errors() {
    let (state, _) = state_with_session();
    let backend = StateBackend::new(state);
    let err = backend
        .preview_bug_report(&"z".repeat(64))
        .await
        .unwrap_err();
    assert!(
        err.contains("not found"),
        "error should mention 'not found': {err}"
    );
}

#[tokio::test]
async fn report_bug_no_confirm_returns_preview_only() {
    let (state, _) = state_with_session();
    let backend = StateBackend::new(state);
    // An unknown fingerprint with confirm:false should give "not found" error
    // (the fingerprint lookup happens before the confirm check).
    let err = backend
        .report_bug(&"y".repeat(64), false)
        .await
        .unwrap_err();
    assert!(err.contains("not found"), "expected not-found error: {err}");
}

#[tokio::test]
async fn report_bug_confirm_no_token_graceful_failure() {
    // When TRUSTY_BUGREPORT_GITHUB_TOKEN is absent and no token file exists,
    // report_bug should return Ok or Err without panicking — no real GitHub call.
    // Because local stores are typically empty in CI, the "not found" error
    // fires before the token check; that is acceptable. The intent is that
    // no panic occurs and no network call is made.
    let (state, _) = state_with_session();
    let backend = StateBackend::new(state);
    // This returns Err (fingerprint not found) — acceptable in the test
    // environment where stores are empty. What matters is no panic and no
    // real GitHub call.
    let _ = backend.report_bug(&"x".repeat(64), true).await;
}