trusty-mpm-daemon 0.2.3

Long-running trusty-mpm daemon: session control, hook interception, artifact serving
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
use super::*;
use axum::http::StatusCode;
use trusty_mpm_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 health_endpoint_responds() {
    assert_eq!(health().await, "ok");
}

#[tokio::test]
async fn current_project_found_and_missing() {
    // `GET /projects/current` returns the project for a registered path
    // and `404` for an unregistered one.
    let state = DaemonState::shared();
    let _ = register_project(
        State(Arc::clone(&state)),
        Json(RegisterProject {
            path: "/work/demo".into(),
        }),
    )
    .await;

    let ok = current_project(
        State(Arc::clone(&state)),
        Query(CurrentProjectQuery {
            path: "/work/demo".into(),
        }),
    )
    .await;
    assert!(ok.is_ok());

    let err = current_project(
        State(state),
        Query(CurrentProjectQuery {
            path: "/work/missing".into(),
        }),
    )
    .await
    .unwrap_err();
    assert_eq!(err.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn discover_projects_returns_array() {
    // `GET /projects/discover` always answers with a (possibly empty) array;
    // it must never error even when `~/.claude/projects/` is absent.
    let state = DaemonState::shared();
    let resp = discover_projects(State(state)).await;
    // The discovered list is well-formed; on CI it is typically empty.
    for project in &resp.0.projects {
        assert!(!project.path.is_empty());
    }
}

#[tokio::test]
async fn register_session_associates_project() {
    // A `POST /sessions` body carrying `project_path` must associate the
    // new session with that project.
    let state = DaemonState::shared();
    let Json(body) = register_session(
        State(Arc::clone(&state)),
        Json(RegisterSession {
            workdir: "/work/demo".into(),
            project_path: Some("/work/demo".into()),
            name: None,
        }),
    )
    .await;
    let id = body.id.0.to_string();
    let listed = state.list_sessions();
    let session = listed
        .iter()
        .find(|s| s.id.0.to_string() == id)
        .expect("session registered");
    assert_eq!(session.project_path, Some(PathBuf::from("/work/demo")));
}

#[tokio::test]
async fn list_sessions_filters_by_project() {
    // `GET /sessions?project=<path>` returns only sessions of that project.
    let state = DaemonState::shared();
    let _ = register_session(
        State(Arc::clone(&state)),
        Json(RegisterSession {
            workdir: "/work/demo".into(),
            project_path: Some("/work/demo".into()),
            name: None,
        }),
    )
    .await;
    let _ = register_session(
        State(Arc::clone(&state)),
        Json(RegisterSession {
            workdir: "/work/other".into(),
            project_path: Some("/work/other".into()),
            name: None,
        }),
    )
    .await;

    let Json(all) = list_sessions(State(Arc::clone(&state)), Query(SessionQuery::default())).await;
    assert_eq!(all.sessions.len(), 2);

    let Json(scoped) = list_sessions(
        State(state),
        Query(SessionQuery {
            project: Some("/work/demo".into()),
        }),
    )
    .await;
    assert_eq!(scoped.sessions.len(), 1);
}

#[tokio::test]
async fn hook_relay_ingests_known_event() {
    let (state, id) = state_with_session();
    let post = HookPost {
        session_id: id.0.to_string(),
        event: HookEvent::PostToolUse,
        payload: serde_json::json!({"tool": "Edit"}),
    };
    let result = ingest_hook(State(state.clone()), Json(post)).await;
    assert!(result.is_ok());
    assert_eq!(state.recent_hook_events().len(), 1);
}

#[tokio::test]
async fn register_and_remove_session() {
    let state = DaemonState::shared();
    let Json(body) = register_session(
        State(state.clone()),
        Json(RegisterSession {
            workdir: "/tmp/new".into(),
            project_path: None,
            name: None,
        }),
    )
    .await;
    let id = body.id.0.to_string();
    assert_eq!(state.list_sessions().len(), 1);
    // Removing it succeeds; removing again is a 404.
    assert!(
        remove_session(State(state.clone()), Path(id.clone()))
            .await
            .is_ok()
    );
    let err = remove_session(State(state), Path(id)).await.unwrap_err();
    assert_eq!(err.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn registered_session_has_friendly_tmux_name() {
    // A registered session must carry a `tmpm-<adj>-<noun>` tmux name
    // derived from its UUID, not the legacy `trusty-mpm-<uuid>` form.
    let state = DaemonState::shared();
    let Json(body) = register_session(
        State(Arc::clone(&state)),
        Json(RegisterSession {
            workdir: "/tmp/friendly".into(),
            project_path: None,
            name: None,
        }),
    )
    .await;
    let id = body.id.0.to_string();
    let listed = state.list_sessions();
    let session = listed
        .iter()
        .find(|s| s.id.0.to_string() == id)
        .expect("session registered");
    assert!(
        session.tmux_name.starts_with("tmpm-"),
        "friendly name: {}",
        session.tmux_name
    );
    assert!(session.tmux_name.len() <= 25);
}

#[tokio::test]
async fn reap_sessions_returns_removed_count() {
    // `DELETE /sessions/dead` always returns a well-formed `{ "removed": N }`
    // body. The exact count depends on whether tmux is installed: with tmux
    // the lone test session (no live tmux session named `tmpm-*`) is reaped
    // (1); without tmux nothing is reaped (0). Either way the registry must
    // not contain a session that is missing from tmux afterwards.
    let (state, _) = state_with_session();
    let Json(body) = reap_sessions(State(Arc::clone(&state))).await;
    let removed = body.removed;
    assert!(removed <= 1, "at most the one test session is reaped");
    assert_eq!(state.list_sessions().len(), 1 - removed);
}

#[tokio::test]
async fn register_session_returns_id_even_without_tmux() {
    // Graceful-degradation invariant: tmux is unavailable in CI, yet
    // `POST /sessions` must still return a JSON body carrying an `id`, and
    // that id must be visible in the subsequent `GET /sessions` snapshot.
    let state = DaemonState::shared();
    let Json(body) = register_session(
        State(Arc::clone(&state)),
        Json(RegisterSession {
            workdir: "/tmp/no-tmux".into(),
            project_path: None,
            name: None,
        }),
    )
    .await;
    let id_str = body.id.0.to_string();
    let listed = state.list_sessions();
    assert_eq!(listed.len(), 1);
    assert_eq!(listed[0].id.0.to_string(), id_str);
}

#[tokio::test]
async fn hook_relay_rejects_bad_session_id() {
    let (state, _) = state_with_session();
    let post = HookPost {
        session_id: "not-a-uuid".into(),
        event: HookEvent::Stop,
        payload: serde_json::Value::Null,
    };
    let err = ingest_hook(State(state), Json(post)).await.unwrap_err();
    assert_eq!(err.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn hook_relay_runs_with_disabled_overseer() {
    // With the overseer disabled (the default), a PreToolUse event must
    // still be ingested normally — the overseer fast-path allows it.
    let (state, id) = state_with_session();
    let post = HookPost {
        session_id: id.0.to_string(),
        event: HookEvent::PreToolUse,
        payload: serde_json::json!({"tool": "Bash", "input": {"command": "ls"}}),
    };
    let result = ingest_hook(State(state.clone()), Json(post)).await;
    assert!(result.is_ok());
    assert_eq!(state.recent_hook_events().len(), 1);
}

#[tokio::test]
async fn session_start_auto_registers_unknown_session() {
    // A `SessionStart` hook for a session the daemon has never seen must
    // auto-register it (connection-driven registration), using the incoming
    // UUID so the session carries the right id.
    let state = DaemonState::shared();
    let new_id = trusty_mpm_core::session::SessionId::new();
    assert!(state.session(new_id).is_none());

    let post = HookPost {
        session_id: new_id.0.to_string(),
        event: HookEvent::SessionStart,
        payload: serde_json::Value::Null,
    };
    let result = ingest_hook(State(state.clone()), Json(post)).await;
    assert!(result.is_ok());

    let registered = state.session(new_id).expect("session auto-registered");
    assert_eq!(registered.id, new_id);
    assert_eq!(
        registered.status,
        trusty_mpm_core::session::SessionStatus::Active
    );
}

#[tokio::test]
async fn non_session_start_event_does_not_auto_register() {
    // Only `SessionStart` auto-registers. A non-start event for an unknown
    // session must not create a session record.
    let state = DaemonState::shared();
    let unknown = trusty_mpm_core::session::SessionId::new();
    let post = HookPost {
        session_id: unknown.0.to_string(),
        event: HookEvent::PreToolUse,
        payload: serde_json::json!({"tool": "Bash"}),
    };
    let _ = ingest_hook(State(state.clone()), Json(post)).await;
    assert!(state.session(unknown).is_none());
}

#[tokio::test]
async fn session_start_for_known_session_does_not_duplicate() {
    // A `SessionStart` for an already-registered session must not create a
    // second record.
    let (state, id) = state_with_session();
    let before = state.list_sessions().len();
    let post = HookPost {
        session_id: id.0.to_string(),
        event: HookEvent::SessionStart,
        payload: serde_json::Value::Null,
    };
    let result = ingest_hook(State(state.clone()), Json(post)).await;
    assert!(result.is_ok());
    assert_eq!(state.list_sessions().len(), before);
}

#[tokio::test]
async fn llm_chat_without_overseer_is_503() {
    // A default daemon has no OpenRouter key, so `POST /llm/chat` reports the
    // capability as unavailable rather than attempting a network call.
    let state = DaemonState::shared();
    let err = llm_chat(
        State(state),
        Json(LlmChatRequest {
            message: "hello".into(),
            history: Vec::new(),
        }),
    )
    .await
    .unwrap_err();
    assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
}

#[tokio::test]
async fn openapi_spec_is_valid() {
    // `GET /api-docs/openapi.json` must return 200 with a document that
    // carries the `openapi` version key and the daemon's title.
    use axum::body::Body;
    use axum::http::Request;
    use tower::ServiceExt;

    let app = router(DaemonState::shared());
    let response = app
        .oneshot(
            Request::builder()
                .uri("/api-docs/openapi.json")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::OK);

    let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
        .await
        .unwrap();
    let spec: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert!(
        spec.get("openapi").is_some(),
        "spec must have an openapi key"
    );
    assert!(
        spec["info"]["title"]
            .as_str()
            .unwrap_or_default()
            .contains("trusty-mpm"),
        "spec title must mention trusty-mpm"
    );
}

#[tokio::test]
async fn pause_then_resume_round_trips() {
    // Pausing flips a session to `Paused`; resuming flips it back to
    // `Active` and clears the pause metadata.
    let (state, id) = state_with_session();
    let Json(body) = pause_session(
        State(Arc::clone(&state)),
        Path(id.0.to_string()),
        Json(PauseRequest {
            summary: Some("mid-task".into()),
        }),
    )
    .await
    .expect("pause succeeds");
    assert!(body.paused);
    assert_eq!(body.summary, "mid-task");

    let paused = state.session(id).expect("session exists");
    assert_eq!(paused.status, SessionStatus::Paused);
    assert_eq!(paused.pause_summary.as_deref(), Some("mid-task"));
    assert!(paused.paused_at.is_some());

    let Json(resumed) = resume_session(State(Arc::clone(&state)), Path(id.0.to_string()))
        .await
        .expect("resume succeeds");
    assert!(resumed.resumed);

    let active = state.session(id).expect("session exists");
    assert_eq!(active.status, SessionStatus::Active);
    assert_eq!(active.paused_at, None);
    assert_eq!(active.pause_summary, None);
}

#[tokio::test]
async fn pause_unknown_session_is_404() {
    let state = DaemonState::shared();
    let err = pause_session(
        State(state),
        Path(SessionId::new().0.to_string()),
        Json(PauseRequest::default()),
    )
    .await
    .unwrap_err();
    assert_eq!(err.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn resume_unpaused_session_is_409() {
    // A session that was never paused cannot be resumed.
    let (state, id) = state_with_session();
    let err = resume_session(State(state), Path(id.0.to_string()))
        .await
        .unwrap_err();
    assert_eq!(err.status(), StatusCode::CONFLICT);
}

#[tokio::test]
async fn command_to_stopped_session_is_409() {
    let state = DaemonState::shared();
    let id = SessionId::new();
    let mut session = Session::new(id, "/tmp/p", ControlModel::Tmux, None);
    session.status = SessionStatus::Stopped;
    state.register_session(session);

    let err = send_command(
        State(state),
        Path(id.0.to_string()),
        Query(CommandQuery::default()),
        Json(CommandRequest {
            command: "help".into(),
        }),
    )
    .await
    .unwrap_err();
    assert_eq!(err.status(), StatusCode::CONFLICT);
}

#[tokio::test]
async fn output_unknown_session_is_404() {
    let state = DaemonState::shared();
    let err = get_output(
        State(state),
        Path(SessionId::new().0.to_string()),
        Query(OutputQuery::default()),
    )
    .await
    .unwrap_err();
    assert_eq!(err.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn pause_resolves_session_by_friendly_name() {
    // The pause endpoint accepts a friendly tmux name, not just a UUID.
    let (state, id) = state_with_session();
    let name = state.session(id).expect("session").tmux_name;
    let Json(body) = pause_session(
        State(Arc::clone(&state)),
        Path(name),
        Json(PauseRequest::default()),
    )
    .await
    .expect("pause by name succeeds");
    assert!(body.paused);
}

#[test]
fn send_command_compress_query_defaults_off() {
    // A `CommandQuery` with no `compress` field deserializes to `None`, so
    // omitting `?compress=` defaults to no compression.
    let query: CommandQuery = serde_json::from_str("{}").expect("empty query deserializes");
    assert_eq!(query.compress, None);
}

#[test]
fn output_query_defaults() {
    // An `OutputQuery` with no fields set has neither a line count nor a
    // compression level.
    let query: OutputQuery = serde_json::from_str("{}").expect("empty query deserializes");
    assert_eq!(query.lines, None);
    assert_eq!(query.compress, None);
}

#[test]
fn compress_level_roundtrips_serde() {
    // `CompressionLevel::Summarise` serializes to the lowercase wire name
    // `"summarise"` and deserializes back to the same variant.
    let json = serde_json::to_string(&CompressionLevel::Summarise).expect("serialize");
    assert_eq!(json, "\"summarise\"");
    let parsed: CompressionLevel = serde_json::from_str(&json).expect("deserialize");
    assert_eq!(parsed, CompressionLevel::Summarise);
}

#[test]
fn compress_level_label_matches_serde() {
    // The lowercase label helper agrees with serde's wire representation.
    assert_eq!(compression_level_label(CompressionLevel::Off), "off");
    assert_eq!(compression_level_label(CompressionLevel::Trim), "trim");
    assert_eq!(
        compression_level_label(CompressionLevel::Summarise),
        "summarise"
    );
    assert_eq!(
        compression_level_label(CompressionLevel::Caveman),
        "caveman"
    );
}

#[test]
fn apply_compression_off_is_passthrough() {
    // With no level, the text is returned unchanged and there is no label.
    let result = apply_compression(None, "raw pane text");
    assert_eq!(result.text, "raw pane text");
    assert_eq!(result.level_label, None);
}

#[test]
fn apply_compression_summarise() {
    // With a level set, the label is recorded and stats reflect the input.
    let raw = "x".repeat(100);
    let result = apply_compression(Some(CompressionLevel::Summarise), &raw);
    assert_eq!(result.level_label.as_deref(), Some("summarise"));
    assert_eq!(result.stats.original_bytes, 100);
}

#[tokio::test]
async fn adopt_tmux_session_handles_missing() {
    // Adopting a session that does not exist (or with tmux absent) is 404.
    let state = DaemonState::shared();
    let result = adopt_tmux_session(
        State(state),
        Json(AdoptRequest {
            session: "trusty-mpm-no-such-session-xyz".into(),
        }),
    )
    .await;
    assert_eq!(result.unwrap_err().status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn tmux_snapshot_unknown_session_is_404() {
    let state = DaemonState::shared();
    let result = tmux_snapshot(State(state), Path("no-such-session-xyz".into())).await;
    assert_eq!(result.unwrap_err().status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn create_checkpoint_returns_id() {
    // `POST /claude-config/checkpoints` returns an `id` and the checkpoint
    // is then visible via the list endpoint.
    let dir = tempfile::tempdir().unwrap();
    let state = DaemonState::shared();
    let Json(body) = create_checkpoint(
        State(Arc::clone(&state)),
        Json(CreateCheckpointRequest {
            project: dir.path().to_path_buf(),
            label: Some("manual".into()),
        }),
    )
    .await
    .expect("create succeeds");
    assert!(!body.id.is_empty());

    let Json(listed) = list_checkpoints(
        State(state),
        Query(CheckpointQuery {
            project: dir.path().to_path_buf(),
        }),
    )
    .await;
    assert_eq!(listed.checkpoints.len(), 1);
}

#[tokio::test]
async fn restore_unknown_checkpoint_is_500() {
    let dir = tempfile::tempdir().unwrap();
    let state = DaemonState::shared();
    let err = restore_checkpoint(
        State(state),
        Json(RestoreRequest {
            project: dir.path().to_path_buf(),
            checkpoint_id: "no-such-checkpoint".into(),
        }),
    )
    .await
    .unwrap_err();
    assert_eq!(err, StatusCode::INTERNAL_SERVER_ERROR);
}

#[tokio::test]
async fn delete_unknown_checkpoint_is_404() {
    let dir = tempfile::tempdir().unwrap();
    let state = DaemonState::shared();
    let err = delete_checkpoint(
        State(state),
        Path("no-such-checkpoint".into()),
        Query(CheckpointQuery {
            project: dir.path().to_path_buf(),
        }),
    )
    .await
    .unwrap_err();
    assert_eq!(err, StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn deploy_profile_returns_checkpoint_id() {
    // `POST /claude-config/deploy` deploys a built-in profile and returns a
    // checkpoint id for undo.
    let dir = tempfile::tempdir().unwrap();
    let state = DaemonState::shared();
    let Json(body) = deploy_profile(
        State(state),
        Json(DeployProfileRequest {
            project: dir.path().to_path_buf(),
            profile_name: "minimal".into(),
            target: None,
        }),
    )
    .await
    .expect("deploy succeeds");
    assert_eq!(body.deployed, "minimal");
    assert!(!body.checkpoint_id.is_empty());
}

#[tokio::test]
async fn deploy_unknown_profile_is_404() {
    let dir = tempfile::tempdir().unwrap();
    let state = DaemonState::shared();
    let err = deploy_profile(
        State(state),
        Json(DeployProfileRequest {
            project: dir.path().to_path_buf(),
            profile_name: "no-such-profile".into(),
            target: None,
        }),
    )
    .await
    .unwrap_err();
    assert_eq!(err, StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn pair_confirm_rejects_bad_code() {
    // A code that was never issued must not pair the daemon. The state is
    // rooted at a temp dir so it ignores any real persisted pairing on disk.
    let dir = tempfile::tempdir().unwrap();
    let state = Arc::new(DaemonState::with_root(dir.path().to_path_buf()));
    let _ = pair_request(State(Arc::clone(&state))).await;
    let Json(confirm) = pair_confirm(
        State(Arc::clone(&state)),
        Json(PairConfirmRequest {
            code: "ZZZZZZ".into(),
            chat_id: 777,
        }),
    )
    .await;
    assert!(!confirm.success);
    assert!(confirm.error.as_deref().unwrap().contains("invalid"));

    let Json(status) = pair_status(State(state)).await;
    assert!(!status.paired);
    assert!(status.chat_id.is_none());
}

#[tokio::test]
async fn discover_sessions_returns_count() {
    // `POST /sessions/discover` returns a well-formed count; with tmux absent
    // (or no Claude panes) on CI it is zero, but the shape must be correct.
    let state = DaemonState::shared();
    let Json(resp) = discover_sessions(State(state)).await;
    assert_eq!(resp.discovered, resp.sessions.len());
}

#[tokio::test]
async fn pair_reset_clears_pairing() {
    // `POST /pair/reset` always reports `reset: true` and leaves the daemon
    // unpaired. The state is rooted at a temp dir so no disk write touches HOME.
    let dir = tempfile::tempdir().unwrap();
    let state = Arc::new(DaemonState::with_root(dir.path().to_path_buf()));
    let Json(resp) = pair_reset(State(Arc::clone(&state))).await;
    assert!(resp.reset);
    let Json(status) = pair_status(State(state)).await;
    assert!(!status.paired);
}

#[tokio::test]
async fn apply_claude_config_unknown_rec_is_404() {
    let dir = tempfile::tempdir().unwrap();
    let state = DaemonState::shared();
    let result = apply_claude_config(
        State(state),
        Json(ApplyConfigRequest {
            project: dir.path().to_path_buf(),
            recommendation_id: "no-such-recommendation".into(),
        }),
    )
    .await;
    assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
}