trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
//! `tm manager` (Layer-3 portfolio) client methods for [`DaemonClient`]
//! (DOC-36 §3.2, epic #2109, WI-6 #2583).
//!
//! Why: `tm manager status|digest|chat` (#2583) is the thin CLI half of the
//! `/api/v1/manager/*` surface — same "wire an endpoint exactly once" rule
//! DOC-35 §1.3 established for `projects.rs`. All three routes are now pinned,
//! shipped contracts: `status` (WI-2 #2579, PR #2598) and `digest`/`chat`
//! (WI-3/WI-4 #2580/#2581, PR #2601) — see `daemon/manager/{status,digest,
//! chat}.rs`. [`PortfolioStatusWire`] mirrors [`super::projects::ProjectStatusWire`]
//! the same way the daemon's `PortfolioStatusResponse` composes
//! `ProjectStatusResponse` per-project. [`ManagerDigestOutcome`]/
//! [`ManagerChatOutcome`] read the daemon's REAL response fields
//! (`digest.rs`'s `DigestResponse` — `narrative`/`generated_by`/`status`;
//! `chat.rs`'s `ChatReplyBody`/`chat_error` — `reply`/`conversation_key` on
//! success, `error`/`message` on degrade) rather than a speculative candidate
//! list, now that the shape is a shipped contract, not a concurrent guess.
//!
//! Two behaviors this module gets deliberately right, both found in review
//! (PR #2600 paired review against PR #2601's shapes):
//!
//! 1. **The daemon's degrade body is not an HTTP error to discard.**
//!    `digest.rs`'s 502/503 branches (`digest_call_failed`, the no-provider
//!    503) return a FULL `DigestResponse` — narrative + status snapshot +
//!    `generated_by: "deterministic_fallback"` — by design, so a consumer can
//!    degrade gracefully without a second call. `chat.rs`'s 400/502/503
//!    branches return `{ error, message }` with an actionable `message`
//!    (never a `reply`, since there is no assistant text to synthesize).
//!    `Self::manager_digest`/`Self::manager_chat` therefore parse the
//!    response body FIRST, regardless of status code, and only fall back to
//!    treating the response as a hard error when the body carries neither
//!    shape — never blindly call `response_or_body_error` (which would
//!    discard exactly the body the daemon went out of its way to send).
//! 2. **A `404` is not always "this daemon is too old."** `digest.rs` ALSO
//!    answers `404` for a legitimate, mounted request — `scope=project:<name>`
//!    naming an unregistered project (`"project '{name}' is not registered"`).
//!    Treating every `404` as "upgrade your daemon" would misreport a typo'd
//!    project name as a version problem. `Self::manager_endpoint_available`
//!    feature-detects via the already-live `GET /api/v1/manager/version`
//!    (`version.rs`'s `advertised_endpoints`, flips `available: true` once
//!    WI-3/WI-4 land) and only degrades to `Ok(None)` when the endpoint is
//!    genuinely unavailable; otherwise the daemon's own 404 body surfaces as
//!    `Err`.
//!
//! What: [`PortfolioStatusWire`]/[`PortfolioTotalsWire`]/
//! [`DeliverableStatusCountsWire`]/[`MilestoneStatusCountsWire`] (the
//! `GET /manager/status` response DTOs), [`ManagerDigestOutcome`] /
//! [`ManagerChatOutcome`] (parsed digest/chat results, success OR degrade),
//! and three [`DaemonClient`] methods: [`DaemonClient::manager_status`],
//! [`DaemonClient::manager_digest`], [`DaemonClient::manager_chat`].
//! Test: `portfolio_status_wire_parses_totals_and_projects`,
//! `portfolio_status_wire_tolerates_missing_fields` (wire-shape unit tests);
//! `manager_digest_outcome_reads_real_daemon_shape`,
//! `manager_digest_outcome_marks_deterministic_fallback`,
//! `manager_chat_outcome_reads_real_daemon_shape`,
//! `manager_chat_outcome_surfaces_error_message_on_degrade` (parse unit
//! tests, bodies built from the real `DigestResponse`/`ChatReplyBody`
//! structs) in this module's `tests` submodule; live HTTP (incl. the 404
//! degrade AND the real-error-body paths) in
//! `crates/trusty-mpm/tests/manager_cli_client.rs`.

use anyhow::Context;
use chrono::{DateTime, Utc};
use reqwest::StatusCode;
use serde::Deserialize;
use serde_json::Value;

use super::DaemonClient;
use super::error::response_or_body_error;
use super::projects::ProjectStatusWire;

/// Portfolio-wide Deliverable-status histogram (client mirror of the daemon's
/// `DeliverableStatusCounts`, reached via `PortfolioTotals`).
///
/// Why: `PortfolioStatusResponse.totals.deliverables` sums every project's
/// Deliverable histogram (`daemon/manager/status.rs::fold_totals`); the
/// client needs its own `Deserialize` mirror, forward-tolerant of any future
/// additive status variant.
/// What: one count per Deliverable status plus the total.
/// Test: `portfolio_status_wire_parses_totals_and_projects`.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct DeliverableStatusCountsWire {
    #[serde(default)]
    pub proposed: usize,
    #[serde(default)]
    pub in_progress: usize,
    #[serde(default)]
    pub blocked: usize,
    #[serde(default)]
    pub complete: usize,
    #[serde(default)]
    pub delivered: usize,
    #[serde(default)]
    pub shipped: usize,
    #[serde(default)]
    pub total: usize,
}

/// Portfolio-wide Milestone-status histogram (client mirror of the daemon's
/// `MilestoneStatusCounts`).
///
/// What: one count per Milestone status, the total, and the dangling-ref
/// count carried through from the per-project rollup.
/// Test: `portfolio_status_wire_parses_totals_and_projects`.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct MilestoneStatusCountsWire {
    #[serde(default)]
    pub proposed: usize,
    #[serde(default)]
    pub in_progress: usize,
    #[serde(default)]
    pub complete: usize,
    #[serde(default)]
    pub shipped: usize,
    #[serde(default)]
    pub total: usize,
    #[serde(default)]
    pub dangling_deliverable_refs: usize,
}

/// Portfolio-wide aggregate totals (client mirror of the daemon's
/// `PortfolioTotals`, `daemon/manager/status.rs`).
///
/// What: the summed session/Deliverable/Milestone histograms plus the most
/// recent activity timestamp across the whole portfolio.
/// Test: `portfolio_status_wire_parses_totals_and_projects`.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct PortfolioTotalsWire {
    #[serde(default)]
    pub sessions: super::projects::SessionStateCountsWire,
    #[serde(default)]
    pub deliverables: DeliverableStatusCountsWire,
    #[serde(default)]
    pub milestones: MilestoneStatusCountsWire,
    #[serde(default)]
    pub last_activity_at: Option<DateTime<Utc>>,
}

/// Deserialized `GET /api/v1/manager/status` rollup (client mirror of the
/// daemon's `PortfolioStatusResponse`, `daemon/manager/status.rs`).
///
/// Why: no `deny_unknown_fields` — forward-tolerant of any additive field a
/// later phase adds, mirroring [`ProjectStatusWire`]'s own tolerance policy.
/// What: the registered-project count, the portfolio-wide totals, and the
/// per-project breakdown (each entry the SAME [`ProjectStatusWire`]
/// `tm projects status` already parses — no reimplementation).
/// Test: `portfolio_status_wire_parses_totals_and_projects`,
/// `portfolio_status_wire_tolerates_missing_fields`.
#[derive(Debug, Clone, Deserialize)]
pub struct PortfolioStatusWire {
    #[serde(default)]
    pub project_count: usize,
    #[serde(default)]
    pub totals: PortfolioTotalsWire,
    #[serde(default)]
    pub projects: Vec<ProjectStatusWire>,
}

/// `generated_by` value the daemon sends for the deterministic templated
/// fallback (`daemon/manager/digest.rs::GENERATED_BY_FALLBACK`).
const GENERATED_BY_FALLBACK: &str = "deterministic_fallback";

/// Parsed result of `GET /api/v1/manager/digest`, covering BOTH the success
/// (200, `generated_by: "llm"`) and degrade (502/503, `generated_by:
/// "deterministic_fallback"`) shapes — both are the same `DigestResponse`
/// JSON body on the wire (`daemon/manager/digest.rs`).
///
/// Why: the daemon's `DigestResponse` always carries `narrative` regardless
/// of status code (see module doc point 1), so one parse function handles
/// every case the CLI needs to render.
/// What: the narrative text and whether it is the deterministic fallback.
/// Test: `manager_digest_outcome_reads_real_daemon_shape`,
/// `manager_digest_outcome_marks_deterministic_fallback`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManagerDigestOutcome {
    /// The narrative text (LLM-authored or the deterministic fallback prose).
    pub narrative: String,
    /// True when `generated_by == "deterministic_fallback"` (no inference
    /// provider configured, or the LLM call failed) — DOC-16 D1.
    pub fallback: bool,
    /// The raw response body, kept for `--json` passthrough.
    pub raw: Value,
}

impl ManagerDigestOutcome {
    /// Build from a `DigestResponse`-shaped body. Returns `None` when the
    /// body carries no `narrative` field at all (not this response shape).
    fn from_body(raw: Value) -> Option<Self> {
        let narrative = raw.get("narrative").and_then(Value::as_str)?.to_string();
        let fallback =
            raw.get("generated_by").and_then(Value::as_str) == Some(GENERATED_BY_FALLBACK);
        Some(Self {
            narrative,
            fallback,
            raw,
        })
    }
}

/// Parsed result of `POST /api/v1/manager/chat`, covering BOTH the success
/// shape (`ChatReplyBody` — `reply`/`conversation_key`/`model`/`turn_count`)
/// and the degrade shape (`chat_error`'s `{ error, message }`, sent on
/// 400/502/503 — `daemon/manager/chat.rs`). There is no deterministic
/// fallback reply to synthesize for chat (unlike digest), so on degrade the
/// daemon's actionable `message` text is surfaced as [`Self::reply`] rather
/// than discarded — the CLI still has something useful to print.
///
/// Why: one parse function for both shapes keeps `manager_chat` from ever
/// needing to special-case status codes beyond the 404 mount-detection (see
/// module doc point 2).
/// What: the reply/message text and the conversation key (echoed by the
/// daemon on success, falls back to the requested key on degrade — the
/// error body never carries one).
/// Test: `manager_chat_outcome_reads_real_daemon_shape`,
/// `manager_chat_outcome_surfaces_error_message_on_degrade`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManagerChatOutcome {
    /// The assistant's reply (success) or the daemon's actionable degrade
    /// message (`chat_error`'s `message` field).
    pub reply: String,
    /// The conversation key this turn was recorded under.
    pub conversation_key: String,
    /// The raw response body, kept for `--json` passthrough.
    pub raw: Value,
}

impl ManagerChatOutcome {
    /// Build from a `ChatReplyBody`- or `chat_error`-shaped body. Returns
    /// `None` when the body carries neither a `reply` nor a `message` field
    /// (not one of chat's two response shapes).
    fn from_body(raw: Value, requested_key: &str) -> Option<Self> {
        let reply = raw
            .get("reply")
            .and_then(Value::as_str)
            .or_else(|| raw.get("message").and_then(Value::as_str))?
            .to_string();
        let conversation_key = raw
            .get("conversation_key")
            .and_then(Value::as_str)
            .unwrap_or(requested_key)
            .to_string();
        Some(Self {
            reply,
            conversation_key,
            raw,
        })
    }
}

/// Parsed result of `POST /api/v1/manager/route-task` (WI-8, #2585).
///
/// Why: backs `tm manager route` (#2587) — the thin CLI wrapper over the
/// advisory routing endpoint. The daemon always returns a `{ project,
/// confidence, rationale, resolved_by }` body on success (200) including the
/// no-match case (`project: null`), so one parse function handles every routed
/// result the CLI renders.
/// What: the resolved project (`None` on no-match), the confidence, the
/// rationale, and the decision path (`resolver` | `disambiguation` |
/// `no_match`).
/// Test: `route_task_outcome_reads_real_daemon_shape`,
/// `route_task_outcome_reads_no_match` in this module's `tests` submodule.
#[derive(Debug, Clone, PartialEq)]
pub struct RouteTaskOutcome {
    /// The resolved project name, or `None` when nothing matched.
    pub project: Option<String>,
    /// Confidence in the resolved route, `[0.0, 1.0]`.
    pub confidence: f32,
    /// Human-readable rationale for the route.
    pub rationale: String,
    /// How the route was decided (`resolver` | `disambiguation` | `no_match`).
    pub resolved_by: String,
    /// The raw response body, kept for `--json` passthrough.
    pub raw: Value,
}

impl RouteTaskOutcome {
    /// Build from a `RouteTaskResponse`-shaped body. Returns `None` when the body
    /// carries no `resolved_by` field at all (not this response shape).
    fn from_body(raw: Value) -> Option<Self> {
        let resolved_by = raw.get("resolved_by").and_then(Value::as_str)?.to_string();
        let project = raw
            .get("project")
            .and_then(Value::as_str)
            .map(str::to_string);
        let confidence = raw.get("confidence").and_then(Value::as_f64).unwrap_or(0.0) as f32;
        let rationale = raw
            .get("rationale")
            .and_then(Value::as_str)
            .unwrap_or_default()
            .to_string();
        Some(Self {
            project,
            confidence,
            rationale,
            resolved_by,
            raw,
        })
    }
}

/// Format a non-JSON (or unrecognized-shape) error body for `anyhow::bail!`.
///
/// Why: `digest.rs`'s 400 (`invalid scope`) and 404 (`project '{name}' is not
/// registered`) responses are plain text, not JSON — `(StatusCode,
/// String)::into_response()` — so there is no `error`/`message` field to
/// prefer; the raw trimmed body IS the daemon's message.
/// What: `"{status}: {trimmed body}"`, or bare `"{status}"` on an empty body.
fn daemon_error_text(status: StatusCode, body_text: &str) -> String {
    let trimmed = body_text.trim();
    if trimmed.is_empty() {
        status.to_string()
    } else {
        format!("{status}: {trimmed}")
    }
}

impl DaemonClient {
    /// Fetch the deterministic portfolio rollup via `GET /api/v1/manager/status`.
    ///
    /// Why: backs `tm manager status` (#2583). No LLM call, no channel/bot
    /// token — DOC-36 §4's local-testability bar. This endpoint has been live
    /// since WI-2 (#2579, PR #2598), so a 404 here is a genuine "unreachable
    /// or ancient daemon" condition and surfaces as an ordinary `Err` via
    /// [`response_or_body_error`], unlike the feature-detected 404 handling
    /// on [`Self::manager_digest`]/[`Self::manager_chat`].
    /// What: GETs the rollup, returns the parsed [`PortfolioStatusWire`].
    /// Test: `portfolio_status_wire_parses_totals_and_projects`; live HTTP via
    /// `crates/trusty-mpm/tests/manager_cli_client.rs`.
    pub async fn manager_status(&self) -> anyhow::Result<PortfolioStatusWire> {
        let url = "/api/v1/manager/status".to_string();
        let resp = self.get(&url).send().await?;
        let status: PortfolioStatusWire = response_or_body_error(resp)
            .await?
            .json()
            .await
            .context("deserialize manager status")?;
        Ok(status)
    }

    /// Whether `GET /api/v1/manager/version` reports `path` as `available`.
    ///
    /// Why: distinguishes "this daemon predates the route" from "the route
    /// exists but rejected this particular request" (module doc point 2) —
    /// called ONLY when a `manager_digest`/`manager_chat` call actually hits a
    /// `404`, so the common case (route mounted, request succeeds or degrades
    /// via a real body) never pays for the extra round trip. Conservative on
    /// any probe failure (network error, non-2xx, unparseable body, missing
    /// entry): treats the endpoint as unavailable, which routes the caller to
    /// the friendlier "upgrade your daemon" message rather than a confusing
    /// probe-failure error.
    /// What: `GET`s `/api/v1/manager/version`, reads the `endpoints` array,
    /// returns `true` iff an entry has `path == path` and `available == true`.
    /// Test: exercised indirectly by
    /// `manager_digest_and_chat_degrade_cleanly_on_404_against_older_daemon`
    /// (unavailable path) and
    /// `manager_digest_client_surfaces_unknown_project_404_against_real_daemon`
    /// (available path) in `crates/trusty-mpm/tests/manager_cli_client.rs`.
    async fn manager_endpoint_available(&self, path: &str) -> bool {
        let url = "/api/v1/manager/version".to_string();
        let Ok(resp) = self.get(&url).send().await else {
            return false;
        };
        if !resp.status().is_success() {
            return false;
        }
        let Ok(body) = resp.json::<Value>().await else {
            return false;
        };
        body.get("endpoints")
            .and_then(Value::as_array)
            .is_some_and(|endpoints| {
                endpoints.iter().any(|ep| {
                    ep.get("path").and_then(Value::as_str) == Some(path)
                        && ep.get("available").and_then(Value::as_bool) == Some(true)
                })
            })
    }

    /// Fetch the portfolio (or single-project) digest via
    /// `GET /api/v1/manager/digest?scope=<scope>`.
    ///
    /// Why: backs `tm manager digest` (#2583), against the shipped
    /// `daemon/manager/digest.rs` contract (WI-3, #2580, PR #2601). See the
    /// module doc's two review-found behaviors: the response body is parsed
    /// BEFORE any status-code branching (the daemon's 502/503 degrade bodies
    /// are full `DigestResponse`s, not bare errors), and a `404` is
    /// feature-detected against `GET /manager/version` rather than assumed to
    /// mean "old daemon" outright (digest.rs also 404s a genuine
    /// `scope=project:<name>` naming an unregistered project).
    /// What: parses the body as `DigestResponse`-shaped on ANY status code;
    /// `Ok(Some(outcome))` when it is; otherwise, on `404`, `Ok(None)` when
    /// [`Self::manager_endpoint_available`] reports the route unmounted, else
    /// `Err` carrying the daemon's own 404 message; on any other
    /// unrecognized-shape non-2xx, `Err` carrying the body text.
    /// Test: `manager_digest_outcome_reads_real_daemon_shape`,
    /// `manager_digest_outcome_marks_deterministic_fallback`; live HTTP in
    /// `crates/trusty-mpm/tests/manager_cli_client.rs`.
    pub async fn manager_digest(
        &self,
        scope: &str,
    ) -> anyhow::Result<Option<ManagerDigestOutcome>> {
        let url = "/api/v1/manager/digest".to_string();
        let resp = self.get(&url).query(&[("scope", scope)]).send().await?;
        let status = resp.status();
        let body_text = resp.text().await.unwrap_or_default();

        // The daemon sends a full DigestResponse body (narrative + status +
        // generated_by) on 200 AND on the 502/503 degrade paths — never
        // discard it just because the status isn't 2xx.
        if let Ok(body) = serde_json::from_str::<Value>(&body_text)
            && let Some(outcome) = ManagerDigestOutcome::from_body(body)
        {
            return Ok(Some(outcome));
        }

        if status == StatusCode::NOT_FOUND {
            if !self
                .manager_endpoint_available("/api/v1/manager/digest")
                .await
            {
                return Ok(None);
            }
            anyhow::bail!(daemon_error_text(status, &body_text));
        }

        anyhow::bail!(daemon_error_text(status, &body_text))
    }

    /// Send one chat turn via `POST /api/v1/manager/chat`.
    ///
    /// Why: backs `tm manager chat` (#2583), against the shipped
    /// `daemon/manager/chat.rs` contract (WI-4, #2581, PR #2601). Same
    /// body-first / feature-detected-404 handling as [`Self::manager_digest`]
    /// (module doc), adapted for chat's two shapes: `ChatReplyBody` on
    /// success, `chat_error`'s `{ error, message }` on 400/502/503 (whose
    /// `message` is surfaced as the outcome's `reply` — there's no assistant
    /// text to synthesize on a degrade, but the daemon's actionable message
    /// is still worth printing rather than discarding). Uses the same longer
    /// chat-scoped timeout [`super::config::CHAT_REQUEST_TIMEOUT`] the
    /// existing `llm_chat`/`coordinator_chat` methods use, since this also
    /// waits on the daemon's own upstream LLM round trip (DOC-36 §3.3).
    /// What: POSTs `{ conversation_key, message }`; parses the body as
    /// chat-shaped on ANY status code; `Ok(None)` on `404` ONLY when
    /// [`Self::manager_endpoint_available`] reports the route unmounted;
    /// `Err` otherwise (carrying the daemon's body message).
    /// Test: `manager_chat_outcome_reads_real_daemon_shape`,
    /// `manager_chat_outcome_surfaces_error_message_on_degrade`; live HTTP in
    /// `crates/trusty-mpm/tests/manager_cli_client.rs`.
    pub async fn manager_chat(
        &self,
        conversation_key: &str,
        message: &str,
    ) -> anyhow::Result<Option<ManagerChatOutcome>> {
        let url = "/api/v1/manager/chat".to_string();
        let resp = self
            .post(&url)
            .json(&serde_json::json!({
                "conversation_key": conversation_key,
                "message": message,
            }))
            .timeout(super::config::CHAT_REQUEST_TIMEOUT)
            .send()
            .await?;
        let status = resp.status();
        let body_text = resp.text().await.unwrap_or_default();

        // chat.rs's `chat_error` sends `{ error, message }` JSON on
        // 400/502/503 — a real, actionable body, never discard it.
        if let Ok(body) = serde_json::from_str::<Value>(&body_text)
            && let Some(outcome) = ManagerChatOutcome::from_body(body, conversation_key)
        {
            return Ok(Some(outcome));
        }

        if status == StatusCode::NOT_FOUND {
            if !self
                .manager_endpoint_available("/api/v1/manager/chat")
                .await
            {
                return Ok(None);
            }
            anyhow::bail!(daemon_error_text(status, &body_text));
        }

        anyhow::bail!(daemon_error_text(status, &body_text))
    }

    /// Route a free-text task to a project via `POST /api/v1/manager/route-task`.
    ///
    /// Why: backs `tm manager route` (#2587), against the shipped
    /// `daemon/manager/route_task.rs` contract (WI-8, #2585). Advisory only —
    /// resolving a route never launches or mutates a session. Same
    /// feature-detected-404 handling as [`Self::manager_digest`]/
    /// [`Self::manager_chat`]: a `404` maps to `Ok(None)` ONLY when
    /// [`Self::manager_endpoint_available`] reports the route unmounted (an older
    /// daemon predating phase 2), so the CLI can print a friendly upgrade hint
    /// rather than a raw 404. Uses the longer chat-scoped timeout since a genuine
    /// tie escalates to the daemon's own upstream LLM round trip (DOC-36 §3.3).
    /// What: POSTs `{ text }`; parses the body as `RouteTaskResponse`-shaped on
    /// ANY status code (the daemon returns 200 even for the advisory no-match);
    /// `Ok(None)` on a genuine mount-detection 404; `Err` otherwise (carrying the
    /// daemon's body message, e.g. a 400 for empty text).
    /// Test: `route_task_outcome_reads_real_daemon_shape`,
    /// `route_task_outcome_reads_no_match`; live HTTP in
    /// `crates/trusty-mpm/tests/manager_routing.rs`.
    pub async fn manager_route_task(&self, text: &str) -> anyhow::Result<Option<RouteTaskOutcome>> {
        let url = "/api/v1/manager/route-task".to_string();
        let resp = self
            .post(&url)
            .json(&serde_json::json!({ "text": text }))
            .timeout(super::config::CHAT_REQUEST_TIMEOUT)
            .send()
            .await?;
        let status = resp.status();
        let body_text = resp.text().await.unwrap_or_default();

        if let Ok(body) = serde_json::from_str::<Value>(&body_text)
            && let Some(outcome) = RouteTaskOutcome::from_body(body)
        {
            return Ok(Some(outcome));
        }

        if status == StatusCode::NOT_FOUND {
            if !self
                .manager_endpoint_available("/api/v1/manager/route-task")
                .await
            {
                return Ok(None);
            }
            anyhow::bail!(daemon_error_text(status, &body_text));
        }

        anyhow::bail!(daemon_error_text(status, &body_text))
    }
}

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

    #[test]
    fn portfolio_status_wire_parses_totals_and_projects() {
        let json = serde_json::json!({
            "project_count": 2,
            "totals": {
                "sessions": { "provisioning": 1, "active": 2, "stopped": 0,
                              "errored": 0, "decommissioned": 0, "total": 3 },
                "deliverables": { "proposed": 1, "in_progress": 1, "total": 2 },
                "milestones": { "complete": 1, "total": 1 },
                "last_activity_at": "2026-07-13T12:00:00Z"
            },
            "projects": [
                { "project_name": "alpha", "repo_url": "u1",
                  "sessions": { "total": 1 } },
                { "project_name": "beta", "repo_url": "u2",
                  "sessions": { "total": 2 } }
            ]
        });
        let status: PortfolioStatusWire = serde_json::from_value(json).unwrap();
        assert_eq!(status.project_count, 2);
        assert_eq!(status.totals.sessions.total, 3);
        assert_eq!(status.totals.deliverables.total, 2);
        assert_eq!(status.totals.milestones.total, 1);
        assert!(status.totals.last_activity_at.is_some());
        assert_eq!(status.projects.len(), 2);
        assert_eq!(status.projects[0].project_name, "alpha");
    }

    #[test]
    fn portfolio_status_wire_tolerates_missing_fields() {
        let json = serde_json::json!({});
        let status: PortfolioStatusWire = serde_json::from_value(json).unwrap();
        assert_eq!(status.project_count, 0);
        assert_eq!(status.totals.sessions.total, 0);
        assert!(status.projects.is_empty());
    }

    /// A 200 `DigestResponse` (`generated_by: "llm"`) — the real success
    /// shape from `daemon/manager/digest.rs`.
    #[test]
    fn manager_digest_outcome_reads_real_daemon_shape() {
        let body = serde_json::json!({
            "scope": "portfolio",
            "generated_by": "llm",
            "model": "anthropic/claude-3-5-haiku",
            "narrative": "Three projects have active sessions; widget is blocked on review.",
            "status": { "project_count": 3, "totals": {}, "projects": [] },
        });
        let outcome = ManagerDigestOutcome::from_body(body).expect("DigestResponse shape");
        assert_eq!(
            outcome.narrative,
            "Three projects have active sessions; widget is blocked on review."
        );
        assert!(!outcome.fallback);
    }

    /// A 502/503 `DigestResponse` degrade body (`generated_by:
    /// "deterministic_fallback"`, plus `error`/`message`) — the real
    /// no-provider / inference-failed shape. This is the case #2600's
    /// original `fallback: bool` read could never detect (dead code, since
    /// the daemon has no such field) — the fix reads `generated_by` instead.
    #[test]
    fn manager_digest_outcome_marks_deterministic_fallback() {
        let body = serde_json::json!({
            "scope": "portfolio",
            "generated_by": "deterministic_fallback",
            "narrative": "[deterministic fallback — no inference provider configured] portfolio rollup:\n- Projects: 1",
            "status": { "project_count": 1, "totals": {}, "projects": [] },
            "error": "inference_unavailable",
            "message": "no inference provider is configured",
        });
        let outcome = ManagerDigestOutcome::from_body(body).expect("DigestResponse shape");
        assert!(outcome.fallback);
        assert!(outcome.narrative.starts_with("[deterministic fallback"));
    }

    /// A body with no `narrative` field at all (e.g. digest's plain-text
    /// 400/404 bodies) is not this shape.
    #[test]
    fn manager_digest_outcome_none_for_non_digest_shape() {
        assert!(ManagerDigestOutcome::from_body(serde_json::json!({ "error": "nope" })).is_none());
    }

    /// A 200 `ChatReplyBody` — the real success shape from
    /// `daemon/manager/chat.rs`.
    #[test]
    fn manager_chat_outcome_reads_real_daemon_shape() {
        let body = serde_json::json!({
            "conversation_key": "cli:alice",
            "reply": "Everything looks healthy right now.",
            "model": "anthropic/claude-3-5-haiku",
            "turn_count": 2,
        });
        let outcome =
            ManagerChatOutcome::from_body(body, "cli:alice").expect("ChatReplyBody shape");
        assert_eq!(outcome.reply, "Everything looks healthy right now.");
        assert_eq!(outcome.conversation_key, "cli:alice");
    }

    /// `chat_error`'s degrade shape (`{ error, message }`, no `reply`, no
    /// `conversation_key`) — the daemon's actionable `message` surfaces as
    /// the outcome's `reply`, and the conversation key falls back to the one
    /// the request sent (the error body never echoes one).
    #[test]
    fn manager_chat_outcome_surfaces_error_message_on_degrade() {
        let body = serde_json::json!({
            "error": "inference_unavailable",
            "message": "no inference provider is configured",
        });
        let outcome = ManagerChatOutcome::from_body(body, "cli:bob").expect("chat_error shape");
        assert_eq!(outcome.reply, "no inference provider is configured");
        assert_eq!(outcome.conversation_key, "cli:bob");
    }

    /// A body with neither `reply` nor `message` is not one of chat's shapes.
    #[test]
    fn manager_chat_outcome_none_for_non_chat_shape() {
        assert!(ManagerChatOutcome::from_body(serde_json::json!({ "ok": true }), "k").is_none());
    }

    /// A resolved `RouteTaskResponse` — the real success shape from
    /// `daemon/manager/route_task.rs`.
    #[test]
    fn route_task_outcome_reads_real_daemon_shape() {
        let body = serde_json::json!({
            "project": "alpha",
            "confidence": 0.95,
            "rationale": "resolved to 'alpha' by exact name match (confidence 0.95)",
            "resolved_by": "resolver",
        });
        let outcome = RouteTaskOutcome::from_body(body).expect("RouteTaskResponse shape");
        assert_eq!(outcome.project.as_deref(), Some("alpha"));
        assert!((outcome.confidence - 0.95).abs() < 1e-6);
        assert_eq!(outcome.resolved_by, "resolver");
    }

    /// The advisory no-match shape (`project: null`, confidence 0).
    #[test]
    fn route_task_outcome_reads_no_match() {
        let body = serde_json::json!({
            "project": null,
            "confidence": 0.0,
            "rationale": "no registered project matched the task",
            "resolved_by": "no_match",
        });
        let outcome = RouteTaskOutcome::from_body(body).expect("no-match shape");
        assert!(outcome.project.is_none());
        assert_eq!(outcome.resolved_by, "no_match");
    }

    /// A body with no `resolved_by` field is not a route-task shape.
    #[test]
    fn route_task_outcome_none_for_non_route_shape() {
        assert!(RouteTaskOutcome::from_body(serde_json::json!({ "error": "nope" })).is_none());
    }
}