Skip to main content

git_paw/broker/
messages.rs

1//! Broker message types, validation, and branch slug conversion.
2//!
3//! Defines [`BrokerMessage`] -- the envelope type for all inter-agent
4//! communication -- along with its payload structs and helper methods.
5
6use std::fmt;
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11/// Validation errors for broker messages.
12#[derive(Debug, thiserror::Error)]
13pub enum MessageError {
14    /// The `agent_id` field is empty or whitespace-only.
15    #[error("agent_id must not be empty")]
16    EmptyAgentId,
17
18    /// The `status` field is empty or whitespace-only.
19    #[error("status field must not be empty")]
20    EmptyStatusField,
21
22    /// The `needs` field is empty or whitespace-only.
23    #[error("needs field must not be empty")]
24    EmptyNeedsField,
25
26    /// The `from` field is empty or whitespace-only.
27    #[error("from field must not be empty")]
28    EmptyFromField,
29
30    /// The `verified_by` field is empty or whitespace-only.
31    #[error("verified_by field must not be empty")]
32    EmptyVerifiedBy,
33
34    /// The `errors` list is empty.
35    #[error("errors list must not be empty")]
36    EmptyErrors,
37
38    /// The `question` field is empty or whitespace-only.
39    #[error("question field must not be empty")]
40    EmptyQuestionField,
41
42    /// The intent `files` array is empty.
43    #[error("intent files list must not be empty")]
44    EmptyIntentFiles,
45
46    /// An entry in the intent `files` array is empty or whitespace-only.
47    #[error("intent files entry must not be empty or whitespace-only")]
48    EmptyIntentFileEntry,
49
50    /// The intent `summary` field is empty or whitespace-only.
51    #[error("intent summary field must not be empty")]
52    EmptyIntentSummary,
53
54    /// The intent `valid_for_seconds` field is zero.
55    #[error("intent valid_for_seconds must be > 0")]
56    ZeroValidForSeconds,
57
58    /// The advanced-main `merged_branch` field is empty or whitespace-only.
59    #[error("merged_branch field must not be empty")]
60    EmptyMergedBranch,
61
62    /// The advanced-main `new_main_sha` field is empty or whitespace-only.
63    #[error("new_main_sha field must not be empty")]
64    EmptyNewMainSha,
65
66    /// The advanced-main `base` field is empty or whitespace-only.
67    #[error("base field must not be empty")]
68    EmptyBase,
69    /// The learning `category` field is empty or whitespace-only.
70    #[error("learning category field must not be empty")]
71    EmptyCategory,
72
73    /// The learning `title` field is empty or whitespace-only.
74    #[error("learning title field must not be empty")]
75    EmptyTitle,
76
77    /// The learning `timestamp` field is empty or whitespace-only.
78    #[error("learning timestamp field must not be empty")]
79    EmptyTimestamp,
80
81    /// JSON deserialization failed.
82    #[error("invalid message JSON: {0}")]
83    Deserialize(#[from] serde_json::Error),
84}
85
86/// Payload for `agent.status` messages.
87///
88/// `cli`, `phase`, and `detail` are optional and serialise with
89/// `skip_serializing_if = "Option::is_none"`, so legacy payloads without these
90/// fields deserialise as `None` and new payloads with `None` omit the field
91/// from the wire bytes — preserving v0.5.0 wire compatibility byte-for-byte.
92///
93/// `Eq` is intentionally not derived: `detail` carries a
94/// [`serde_json::Value`], which is `PartialEq` but not `Eq`.
95#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
96pub struct StatusPayload {
97    /// Current status label (e.g. `"working"`, `"idle"`).
98    pub status: String,
99    /// List of files modified by the agent.
100    pub modified_files: Vec<String>,
101    /// Optional human-readable message.
102    pub message: Option<String>,
103    /// Optional CLI name (e.g. `"claude"`) identifying the CLI running in the
104    /// publishing agent's pane. The supervisor pane resolves this from
105    /// `[supervisor].cli` configuration; coding-agent panes typically omit
106    /// the field and rely on the broker's watch-target map.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub cli: Option<String>,
109    /// Optional free-form phase label (e.g. `"watching"`, `"merging"`) for
110    /// the publishing agent's current lifecycle phase. An open string — the
111    /// broker does not validate the set of values, so the supervisor's phase
112    /// taxonomy (`sweep`, `audit`, `merge`, `feedback`, `intent_watch`,
113    /// `learnings`, `idle`, `checkpoint`) can grow without a wire change. The
114    /// dashboard prefers this label over the message-type-derived
115    /// `status_label()` when rendering the supervisor's row.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub phase: Option<String>,
118    /// Optional phase-specific structured detail body. Free-form JSON; the
119    /// broker does not validate its shape. Populated by the supervisor's
120    /// introspection emissions (e.g. `{ "branch": "feat/x", "audit_step":
121    /// "tests" }` for `phase = "audit"`) and surfaced by the MCP
122    /// `get_session_status` tool. Consumers treat an unrecognised shape
123    /// gracefully — extracting documented fields and ignoring the rest.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub detail: Option<serde_json::Value>,
126}
127
128/// Payload for `agent.artifact` messages.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct ArtifactPayload {
131    /// Current status label (e.g. `"done"`).
132    pub status: String,
133    /// List of exported symbols or public API items.
134    pub exports: Vec<String>,
135    /// List of files modified by the agent.
136    pub modified_files: Vec<String>,
137}
138
139/// Payload for `agent.blocked` messages.
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141pub struct BlockedPayload {
142    /// What the agent needs to proceed.
143    pub needs: String,
144    /// Agent ID of the agent that can unblock the sender.
145    pub from: String,
146}
147
148/// Payload for `agent.verified` messages.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct VerifiedPayload {
151    /// Agent ID of the verifier (typically `"supervisor"`).
152    pub verified_by: String,
153    /// Optional human-readable summary of the verification result.
154    pub message: Option<String>,
155}
156
157/// Payload for `agent.question` messages.
158///
159/// Wire format: `{"type": "agent.question", "agent_id": "<slug>", "payload": {"question": "<text>"}}`.
160/// The `question` field MUST NOT be empty. Question messages are routed to the
161/// `"supervisor"` inbox by the broker delivery layer.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct QuestionPayload {
164    /// The question text the agent is asking.
165    pub question: String,
166}
167
168/// A declared region within a file an agent intends to touch.
169///
170/// Serialised as a `tag = "kind"` enum so each region carries an explicit
171/// `kind` discriminator on the wire (`{"kind": "function", "name": "..."}`).
172/// Tagged (not untagged) serialisation is deliberate: an unknown `kind` fails
173/// to deserialise loudly rather than silently falling through to whichever
174/// variant serde tries first — a dropped region would weaken the detector's
175/// signal. The set is closed at four kinds in v0.6.0; adding a kind is an
176/// additive wire change.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(tag = "kind", rename_all = "lowercase")]
179pub enum Region {
180    /// A named function / method symbol.
181    Function {
182        /// The symbol name, compared as an opaque string (no source parsing).
183        name: String,
184    },
185    /// A named class / struct / type symbol.
186    Class {
187        /// The symbol name, compared as an opaque string.
188        name: String,
189    },
190    /// A prose / config landmark (Markdown heading, config block, etc.) for
191    /// files without code symbols.
192    Block {
193        /// The free-form landmark text.
194        anchor: String,
195    },
196    /// A line-range hint, used only when symbolic names don't fit.
197    Range {
198        /// First line of the range (1-based, inclusive).
199        start_line: u32,
200        /// Last line of the range (inclusive).
201        end_line: u32,
202    },
203}
204
205impl fmt::Display for Region {
206    /// Renders a region as `kind name` (or `range start-end`) for warning
207    /// text and dashboard summaries.
208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209        match self {
210            Self::Function { name } => write!(f, "function {name}"),
211            Self::Class { name } => write!(f, "class {name}"),
212            Self::Block { anchor } => write!(f, "block {anchor}"),
213            Self::Range {
214                start_line,
215                end_line,
216            } => write!(f, "range {start_line}-{end_line}"),
217        }
218    }
219}
220
221/// One entry in an intent's `files` array.
222///
223/// Accepts EITHER the v0.5.0 plain-string shape (`"src/main.rs"` → file-level
224/// intent) OR the v0.6.0 object shape (`{ "path": "...", "regions": [...] }`).
225/// Both forms may appear in the same array. Serialised via an `untagged` enum
226/// so a [`FileIntent::Path`] round-trips to a bare JSON string — preserving
227/// v0.5.0 wire bytes for string-only publishers — while
228/// [`FileIntent::Detailed`] round-trips to an object. An empty `regions` vec
229/// is omitted from the wire bytes (a detailed entry with no regions is
230/// equivalent to the plain string form).
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232#[serde(untagged)]
233pub enum FileIntent {
234    /// File-level intent: just a path, no declared regions (v0.5.0 shape).
235    Path(String),
236    /// File intent with optional declared regions (v0.6.0 shape).
237    Detailed {
238        /// The file path.
239        path: String,
240        /// Declared regions within the file; empty / omitted means
241        /// file-level intent.
242        #[serde(default, skip_serializing_if = "Vec::is_empty")]
243        regions: Vec<Region>,
244    },
245}
246
247impl FileIntent {
248    /// Returns the file path regardless of which shape this entry uses.
249    #[must_use]
250    pub fn path(&self) -> &str {
251        match self {
252            Self::Path(p) | Self::Detailed { path: p, .. } => p,
253        }
254    }
255
256    /// Returns the declared regions, or `None` for a file-level intent.
257    ///
258    /// A plain-string entry and an object entry with an empty `regions` vec
259    /// both report `None` — both mean "file-level intent, no regions".
260    #[must_use]
261    pub fn regions(&self) -> Option<&[Region]> {
262        match self {
263            Self::Path(_) => None,
264            Self::Detailed { regions, .. } if regions.is_empty() => None,
265            Self::Detailed { regions, .. } => Some(regions),
266        }
267    }
268}
269
270impl From<&str> for FileIntent {
271    fn from(s: &str) -> Self {
272        Self::Path(s.to_string())
273    }
274}
275
276/// Payload for `agent.intent` messages.
277///
278/// Wire format: `{"type": "agent.intent", "agent_id": "<slug>", "payload": {...}}`.
279/// `files` declares paths the agent plans to modify (relative to the repository
280/// root; globs are permitted but discouraged). Each entry is a [`FileIntent`] —
281/// either a plain path string (v0.5.0 file-level intent) or an object carrying
282/// optional [`Region`] hints. `summary` is a one-line human description.
283/// `valid_for_seconds` is a relative TTL after which a consumer MAY treat the
284/// intent as stale.
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286pub struct IntentPayload {
287    /// File intents the agent intends to modify — plain paths or
288    /// `{ path, regions }` objects.
289    pub files: Vec<FileIntent>,
290    /// One-line human description of the planned change.
291    pub summary: String,
292    /// Relative TTL in seconds (strictly positive).
293    pub valid_for_seconds: u64,
294}
295
296/// Payload for `agent.feedback` messages.
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298pub struct FeedbackPayload {
299    /// Agent ID of the sender (typically `"supervisor"`).
300    pub from: String,
301    /// List of error messages the target agent should address.
302    pub errors: Vec<String>,
303}
304
305/// Payload for `agent.advanced-main` messages.
306///
307/// Published by the supervisor after a successful merge to the repository's
308/// default branch so downstream agents learn the base moved without polling
309/// git directly. The wire shape is flat — the payload fields sit at the top
310/// level of the envelope alongside the `"type"` discriminator (see
311/// [`BrokerMessage::AdvancedMain`]'s `#[serde(flatten)]`), matching the curl
312/// example the supervisor skill teaches.
313///
314/// All fields are required except `summary`, which the publishing supervisor
315/// LLM populates with a one-line human-readable description and which
316/// serialises with `skip_serializing_if = "Option::is_none"`.
317#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
318pub struct AdvancedMainPayload {
319    /// Who advanced main — typically `"supervisor"`.
320    pub from: String,
321    /// The branch that was just merged.
322    pub merged_branch: String,
323    /// The new abbreviated SHA of the default branch (12+ chars by
324    /// convention; the broker does not validate length or existence).
325    pub new_main_sha: String,
326    /// The base branch that advanced — the resolved default-branch name
327    /// (typically `"main"`), carried explicitly so consumers need not look
328    /// up the session's default branch.
329    pub base: String,
330    /// When the merge landed, as a UTC timestamp.
331    pub merged_at: DateTime<Utc>,
332    /// Optional one-line human-readable summary of what merged.
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub summary: Option<String>,
335}
336
337impl AdvancedMainPayload {
338    /// Returns the deterministic dedup id for this advance event.
339    ///
340    /// Convenience wrapper over [`advanced_main_id`] using this payload's
341    /// fields. See that function for the canonical input and hashing
342    /// contract.
343    #[must_use]
344    pub fn deterministic_id(&self) -> String {
345        advanced_main_id(
346            &self.merged_branch,
347            &self.new_main_sha,
348            &self.base,
349            self.merged_at,
350        )
351    }
352}
353
354/// Computes the deterministic dedup `id` for an `agent.advanced-main` event.
355///
356/// Reuses the `agent.learning` id-hashing pattern from the
357/// `agent-learning-variant` capability — the std-hash shape (no third-party
358/// crates): a `std::collections::hash_map::DefaultHasher` over a canonical,
359/// newline-delimited serialisation of
360/// `merged_branch | new_main_sha | base | hour_bucket`, rendered as a
361/// zero-padded 16-hex-char (64-bit) string. `hour_bucket` is the UTC
362/// `YYYY-MM-DDTHH` truncation of `merged_at`.
363///
364/// The hour bucket is a deduplication safety net: re-emitting the same merge
365/// within the same UTC hour yields an identical id, while the same merge
366/// across an hour boundary yields a different id (matching the learning
367/// variant's recurrence-detection contract). Because `new_main_sha` is unique
368/// per merge, distinct merges effectively never collide regardless of bucket.
369///
370/// `DefaultHasher` is seeded with fixed keys, so the id is stable within a
371/// session and across processes built from the same toolchain — sufficient
372/// for in-session / in-log dedup, which is all this id is used for.
373#[must_use]
374pub fn advanced_main_id(
375    merged_branch: &str,
376    new_main_sha: &str,
377    base: &str,
378    merged_at: DateTime<Utc>,
379) -> String {
380    use std::hash::{Hash as _, Hasher as _};
381
382    let hour_bucket = merged_at.format("%Y-%m-%dT%H").to_string();
383    let canonical = format!("{merged_branch}\n{new_main_sha}\n{base}\n{hour_bucket}");
384    let mut hasher = std::collections::hash_map::DefaultHasher::new();
385    canonical.hash(&mut hasher);
386    // u64 -> zero-padded 16 hex chars, matching agent.learning's std-hash id.
387    format!("{:016x}", hasher.finish())
388}
389
390/// Payload for `agent.learning` messages.
391///
392/// Carries one structured learning record produced by the broker's learnings
393/// aggregator (see [`crate::broker::learnings`]). The shape is fixed across
394/// all categories: `category` is an *open* string tag (consumers filter on
395/// it; descendant changes may add values without a broker change), and `body`
396/// is a category-specific structured object typed as [`serde_json::Value`].
397///
398/// `branch_id` is optional and omitted from the wire bytes when `None`
399/// (cross-cutting records such as permission patterns and conflict pairs are
400/// not scoped to a single branch). The `id` is the deterministic dedup hash
401/// produced by [`crate::broker::learnings::LearningRecord::deterministic_id`].
402///
403/// Note: unlike the other `agent.*` variants, the sender `agent_id` lives in
404/// the payload rather than the envelope (this variant has no separate
405/// envelope `agent_id`). [`BrokerMessage::agent_id`] resolves it from here.
406#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
407pub struct LearningPayload {
408    /// Deterministic dedup id — a stable 16-hex-char (64-bit) hash over the
409    /// record's canonical serialisation. Stable for the same logical record
410    /// within a UTC hour.
411    pub id: String,
412    /// The publishing agent id (typically `"supervisor"`, since the
413    /// aggregator runs in the broker/supervisor context).
414    pub agent_id: String,
415    /// Branch the learning is scoped to; `None` (and omitted on the wire) for
416    /// cross-cutting records.
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub branch_id: Option<String>,
419    /// Open category tag — one of `conflict_event`, `stuck_duration`,
420    /// `recovery_cycles`, `permission_pattern`, or any value added by a
421    /// descendant change.
422    pub category: String,
423    /// Short human-readable summary.
424    pub title: String,
425    /// Category-specific structured body.
426    pub body: serde_json::Value,
427    /// ISO 8601 UTC timestamp.
428    pub timestamp: String,
429}
430
431/// Envelope for all inter-agent messages.
432///
433/// The wire format uses JSON with an internally tagged `"type"` discriminator
434/// whose values are `"agent.status"`, `"agent.artifact"`, `"agent.blocked"`,
435/// `"agent.verified"`, `"agent.feedback"`, `"agent.question"`, `"agent.intent"`,
436/// `"agent.advanced-main"`, `"agent.learning"`, and `"supervisor.verify-now"`.
437/// The last is broker-emitted rather than agent-published; see
438/// [`BrokerMessage::VerifyNow`].
439///
440/// `Eq` is intentionally not derived: the `agent.learning` payload carries a
441/// [`serde_json::Value`] body, which is `PartialEq` but not `Eq`.
442#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
443#[serde(tag = "type")]
444pub enum BrokerMessage {
445    /// Status heartbeat -- not routed to inboxes.
446    #[serde(rename = "agent.status")]
447    Status {
448        /// Sender agent ID (slugified branch name).
449        agent_id: String,
450        /// Status payload.
451        payload: StatusPayload,
452    },
453    /// Artifact announcement -- broadcast to all peers.
454    #[serde(rename = "agent.artifact")]
455    Artifact {
456        /// Sender agent ID.
457        agent_id: String,
458        /// Artifact payload.
459        payload: ArtifactPayload,
460    },
461    /// Blocked notification -- sent to the target agent.
462    #[serde(rename = "agent.blocked")]
463    Blocked {
464        /// Sender agent ID.
465        agent_id: String,
466        /// Blocked payload (contains `from` -- the unblocking agent).
467        payload: BlockedPayload,
468    },
469    /// Verification acknowledgement -- broadcast to all peers.
470    #[serde(rename = "agent.verified")]
471    Verified {
472        /// Target agent ID (the agent whose work was verified).
473        agent_id: String,
474        /// Verified payload (contains `verified_by` -- the sender).
475        payload: VerifiedPayload,
476    },
477    /// Feedback from a verifier -- delivered to the target agent only.
478    #[serde(rename = "agent.feedback")]
479    Feedback {
480        /// Target agent ID (the agent receiving feedback).
481        agent_id: String,
482        /// Feedback payload (contains `from` -- the sender).
483        payload: FeedbackPayload,
484    },
485    /// Agent question -- delivered to the `"supervisor"` inbox for human reply.
486    #[serde(rename = "agent.question")]
487    Question {
488        /// Sender agent ID (the agent asking the question).
489        agent_id: String,
490        /// Question payload.
491        payload: QuestionPayload,
492    },
493    /// Intent announcement -- broadcast to every other registered agent's inbox.
494    ///
495    /// Lets peers (and the broker conflict detector) see which files an
496    /// agent is about to modify before any commit lands.
497    #[serde(rename = "agent.intent")]
498    Intent {
499        /// Sender agent ID (the agent declaring the intent).
500        agent_id: String,
501        /// Intent payload.
502        payload: IntentPayload,
503    },
504    /// Main-advanced notification -- published by the supervisor after a
505    /// successful merge to the default branch, broadcast to every registered
506    /// agent's inbox so dependents learn the base moved.
507    ///
508    /// The payload is flattened into the envelope (its fields sit at the top
509    /// level alongside `"type"`, not nested under a `payload` key) so the
510    /// wire shape matches the curl example the supervisor skill teaches. The
511    /// sender identity is the payload's `from` field (typically
512    /// `"supervisor"`), surfaced through [`BrokerMessage::agent_id`].
513    #[serde(rename = "agent.advanced-main")]
514    AdvancedMain {
515        /// Flattened advanced-main payload (`from`, `merged_branch`,
516        /// `new_main_sha`, `base`, `merged_at`, optional `summary`).
517        #[serde(flatten)]
518        payload: AdvancedMainPayload,
519    },
520    /// Structured learning record -- published by the broker's learnings
521    /// aggregator when `[supervisor] learnings = true` and broker publish is
522    /// active (see [`crate::broker::learnings`]). Carries a deterministic
523    /// dedup `id` so consumers can collapse re-emissions. Routed to the
524    /// scoped `branch_id` inbox when present, otherwise the supervisor inbox;
525    /// always retained in the broker message log. The variant is additive --
526    /// existing consumers ignore unknown types per the broker-messages
527    /// contract.
528    #[serde(rename = "agent.learning")]
529    Learning {
530        /// Learning payload (carries its own `agent_id`).
531        payload: LearningPayload,
532    },
533    /// Supervisor verification nudge -- emitted by the broker (not by an
534    /// agent) when an `agent.artifact { status: "committed" }` arrives and
535    /// `[supervisor].verify_on_commit_nudge` is enabled. Delivered to the
536    /// `"supervisor"` inbox so per-commit verification is triggered by an
537    /// explicit event rather than relying on the supervisor's sweep cadence
538    /// to notice the commit.
539    ///
540    /// Unlike the `agent.*` variants this carries a `branch_id` directly
541    /// (the committing branch) rather than a sender `agent_id` plus a payload
542    /// -- the message originates from the broker itself, so there is no
543    /// publishing agent.
544    #[serde(rename = "supervisor.verify-now")]
545    VerifyNow {
546        /// The committing branch whose commit should be verified now. Copied
547        /// verbatim from the triggering artifact's `agent_id`.
548        branch_id: String,
549    },
550}
551
552impl BrokerMessage {
553    /// Deserializes and validates a broker message from a JSON string.
554    ///
555    /// Returns [`MessageError`] if the JSON is malformed or the `agent_id` is
556    /// invalid.
557    pub fn from_json(input: &str) -> Result<Self, MessageError> {
558        let msg: Self = serde_json::from_str(input)?;
559        msg.validate()?;
560        Ok(msg)
561    }
562
563    /// Returns the `agent_id` field from whichever variant.
564    pub fn agent_id(&self) -> &str {
565        match self {
566            Self::Status { agent_id, .. }
567            | Self::Artifact { agent_id, .. }
568            | Self::Blocked { agent_id, .. }
569            | Self::Verified { agent_id, .. }
570            | Self::Feedback { agent_id, .. }
571            | Self::Question { agent_id, .. }
572            | Self::Intent { agent_id, .. } => agent_id,
573            // `AdvancedMain` has no top-level `agent_id`; the sender identity
574            // is the payload's `from` field (typically `"supervisor"`).
575            Self::AdvancedMain { payload } => &payload.from,
576            // `Learning` carries its sender in the payload (no envelope id).
577            Self::Learning { payload } => &payload.agent_id,
578            // `VerifyNow` has no publishing agent; the closest identity is the
579            // committing branch it nudges verification for.
580            Self::VerifyNow { branch_id } => branch_id,
581        }
582    }
583
584    /// Returns a short status label for the message.
585    ///
586    /// - `Status` returns `payload.status` (e.g. `"working"`)
587    /// - `Artifact` returns `payload.status` (e.g. `"done"`)
588    /// - `Blocked` returns `"blocked"`
589    /// - `Verified` returns `"verified"`
590    /// - `Feedback` returns `"feedback"`
591    /// - `Question` returns `"question"`
592    /// - `Intent` returns `"intent"`
593    /// - `AdvancedMain` returns `"advanced-main"`
594    /// - `VerifyNow` returns `"verify-now"`
595    pub fn status_label(&self) -> &str {
596        match self {
597            Self::Status { payload, .. } => &payload.status,
598            Self::Artifact { payload, .. } => &payload.status,
599            Self::Blocked { .. } => "blocked",
600            Self::Verified { .. } => "verified",
601            Self::Feedback { .. } => "feedback",
602            Self::Question { .. } => "question",
603            Self::Intent { .. } => "intent",
604            Self::AdvancedMain { .. } => "advanced-main",
605            Self::Learning { .. } => "learning",
606            Self::VerifyNow { .. } => "verify-now",
607        }
608    }
609
610    /// Validates all fields according to the broker message spec.
611    ///
612    /// The `agent_id` *shape* is enforced at the HTTP boundary by
613    /// `src/broker/server.rs::publish` against the canonical regex
614    /// `^(supervisor|feat/[a-z0-9][a-z0-9-]+|feat-[a-z0-9][a-z0-9-]+)$`
615    /// — this validator only catches the empty-or-whitespace case so
616    /// non-HTTP callers still trip a clear error on garbage input
617    /// before the typed value flows further.
618    fn validate(&self) -> Result<(), MessageError> {
619        let id = self.agent_id();
620        if id.trim().is_empty() {
621            return Err(MessageError::EmptyAgentId);
622        }
623        match self {
624            Self::Status { payload, .. } => {
625                if payload.status.trim().is_empty() {
626                    return Err(MessageError::EmptyStatusField);
627                }
628            }
629            Self::Artifact { payload, .. } => {
630                if payload.status.trim().is_empty() {
631                    return Err(MessageError::EmptyStatusField);
632                }
633            }
634            Self::Blocked { payload, .. } => {
635                if payload.needs.trim().is_empty() {
636                    return Err(MessageError::EmptyNeedsField);
637                }
638                if payload.from.trim().is_empty() {
639                    return Err(MessageError::EmptyFromField);
640                }
641            }
642            Self::Verified { payload, .. } => {
643                if payload.verified_by.trim().is_empty() {
644                    return Err(MessageError::EmptyVerifiedBy);
645                }
646            }
647            Self::Feedback { payload, .. } => {
648                if payload.from.trim().is_empty() {
649                    return Err(MessageError::EmptyFromField);
650                }
651                if payload.errors.is_empty() {
652                    return Err(MessageError::EmptyErrors);
653                }
654            }
655            Self::Question { payload, .. } => {
656                if payload.question.trim().is_empty() {
657                    return Err(MessageError::EmptyQuestionField);
658                }
659            }
660            Self::Intent { payload, .. } => {
661                if payload.files.is_empty() {
662                    return Err(MessageError::EmptyIntentFiles);
663                }
664                if payload.files.iter().any(|f| f.path().trim().is_empty()) {
665                    return Err(MessageError::EmptyIntentFileEntry);
666                }
667                if payload.summary.trim().is_empty() {
668                    return Err(MessageError::EmptyIntentSummary);
669                }
670                if payload.valid_for_seconds == 0 {
671                    return Err(MessageError::ZeroValidForSeconds);
672                }
673            }
674            Self::AdvancedMain { payload } => {
675                // `from` is this message's `agent_id()`, so the empty-id guard
676                // at the top of this method already rejects a blank `from`.
677                // The remaining required string fields are checked here so a
678                // present-but-blank value trips a clear, field-named error.
679                // `merged_at` is typed as `DateTime<Utc>`, so serde rejects an
680                // absent or malformed timestamp before this validator runs.
681                if payload.merged_branch.trim().is_empty() {
682                    return Err(MessageError::EmptyMergedBranch);
683                }
684                if payload.new_main_sha.trim().is_empty() {
685                    return Err(MessageError::EmptyNewMainSha);
686                }
687                if payload.base.trim().is_empty() {
688                    return Err(MessageError::EmptyBase);
689                }
690            }
691            Self::Learning { payload } => {
692                // The empty-agent-id guard at the top already covers a blank
693                // `payload.agent_id`. `body` presence is guaranteed by serde
694                // (a required field); absence surfaces as a deserialize error
695                // before we get here. We only reject present-but-empty
696                // required string fields.
697                if payload.category.trim().is_empty() {
698                    return Err(MessageError::EmptyCategory);
699                }
700                if payload.title.trim().is_empty() {
701                    return Err(MessageError::EmptyTitle);
702                }
703                if payload.timestamp.trim().is_empty() {
704                    return Err(MessageError::EmptyTimestamp);
705                }
706            }
707            // `branch_id` is the message's `agent_id()`, so the empty-id guard
708            // at the top of this method already rejects a blank branch.
709            Self::VerifyNow { .. } => {}
710        }
711        Ok(())
712    }
713}
714
715impl fmt::Display for BrokerMessage {
716    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
717        match self {
718            Self::Status { agent_id, payload } => {
719                write!(
720                    f,
721                    "[{agent_id}] status: {} ({} files modified)",
722                    payload.status,
723                    payload.modified_files.len()
724                )
725            }
726            Self::Artifact {
727                agent_id, payload, ..
728            } => {
729                if payload.exports.is_empty() {
730                    write!(f, "[{agent_id}] artifact: {}", payload.status)
731                } else {
732                    write!(
733                        f,
734                        "[{agent_id}] artifact: {} \u{2014} exports: {}",
735                        payload.status,
736                        payload.exports.join(", ")
737                    )
738                }
739            }
740            Self::Blocked {
741                agent_id, payload, ..
742            } => {
743                write!(
744                    f,
745                    "[{agent_id}] blocked: needs {} from {}",
746                    payload.needs, payload.from
747                )
748            }
749            Self::Verified {
750                agent_id, payload, ..
751            } => {
752                if let Some(message) = &payload.message {
753                    write!(
754                        f,
755                        "[{agent_id}] verified by {} \u{2014} {message}",
756                        payload.verified_by
757                    )
758                } else {
759                    write!(f, "[{agent_id}] verified by {}", payload.verified_by)
760                }
761            }
762            Self::Feedback {
763                agent_id, payload, ..
764            } => {
765                write!(
766                    f,
767                    "[{agent_id}] feedback from {}: {} errors",
768                    payload.from,
769                    payload.errors.len()
770                )
771            }
772            Self::Question {
773                agent_id, payload, ..
774            } => {
775                write!(f, "[{agent_id}] question: {}", payload.question)
776            }
777            Self::Intent {
778                agent_id, payload, ..
779            } => {
780                write!(
781                    f,
782                    "[{agent_id}] intent: {} files for {}s \u{2014} {}",
783                    payload.files.len(),
784                    payload.valid_for_seconds,
785                    payload.summary,
786                )
787            }
788            Self::AdvancedMain { payload } => {
789                write!(
790                    f,
791                    "[{}] advanced-main: {} \u{2192} {} ({})",
792                    payload.from, payload.merged_branch, payload.base, payload.new_main_sha
793                )
794            }
795            Self::Learning { payload } => {
796                let scope = payload.branch_id.as_deref().unwrap_or("*");
797                write!(
798                    f,
799                    "[{}] learning ({}/{}): {}",
800                    payload.agent_id, payload.category, scope, payload.title
801                )
802            }
803            Self::VerifyNow { branch_id } => {
804                write!(f, "[{branch_id}] verify-now")
805            }
806        }
807    }
808}
809
810/// Converts a git branch name into a stable broker `agent_id` slug.
811///
812/// Applies a 5-step normalization algorithm:
813///
814/// 1. Convert to ASCII lowercase
815/// 2. Replace any character not in `[a-z0-9_]` with `-`
816/// 3. Collapse consecutive `-` into a single `-`
817/// 4. Trim leading and trailing `-`
818/// 5. If the result is empty, return `"agent"`
819///
820/// # Examples
821///
822/// - `"feat/http-broker"` → `"feat-http-broker"`
823/// - `"a/b/c"` → `"a-b-c"`
824/// - `"FEAT/X"` → `"feat-x"`
825/// - `""` → `"agent"`
826/// - `"---"` → `"agent"`
827pub fn slugify_branch(name: &str) -> String {
828    // Step 1: to ASCII lowercase
829    let lowered = name.to_ascii_lowercase();
830
831    // Step 2: replace non-[a-z0-9_] with -
832    let replaced: String = lowered
833        .chars()
834        .map(|c| {
835            if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' {
836                c
837            } else {
838                '-'
839            }
840        })
841        .collect();
842
843    // Step 3: collapse consecutive - to single -
844    let mut collapsed = String::with_capacity(replaced.len());
845    let mut prev_dash = false;
846    for c in replaced.chars() {
847        if c == '-' {
848            if !prev_dash {
849                collapsed.push('-');
850            }
851            prev_dash = true;
852        } else {
853            collapsed.push(c);
854            prev_dash = false;
855        }
856    }
857
858    // Step 4: trim leading/trailing -
859    let trimmed = collapsed.trim_matches('-');
860
861    // Step 5: if empty, return "agent"
862    if trimmed.is_empty() {
863        "agent".to_string()
864    } else {
865        trimmed.to_string()
866    }
867}
868
869#[cfg(test)]
870mod tests {
871    use super::*;
872
873    fn make_status(agent_id: &str, status: &str) -> BrokerMessage {
874        BrokerMessage::Status {
875            agent_id: agent_id.to_string(),
876            payload: StatusPayload {
877                status: status.to_string(),
878                modified_files: vec![],
879                message: None,
880                ..Default::default()
881            },
882        }
883    }
884
885    fn make_artifact(agent_id: &str, status: &str, exports: &[&str]) -> BrokerMessage {
886        BrokerMessage::Artifact {
887            agent_id: agent_id.to_string(),
888            payload: ArtifactPayload {
889                status: status.to_string(),
890                exports: exports.iter().map(|s| (*s).to_string()).collect(),
891                modified_files: vec!["src/main.rs".to_string()],
892            },
893        }
894    }
895
896    fn make_blocked(agent_id: &str, needs: &str, from: &str) -> BrokerMessage {
897        BrokerMessage::Blocked {
898            agent_id: agent_id.to_string(),
899            payload: BlockedPayload {
900                needs: needs.to_string(),
901                from: from.to_string(),
902            },
903        }
904    }
905
906    #[test]
907    fn slugify_branch_replaces_slashes() {
908        assert_eq!(slugify_branch("feat/errors"), "feat-errors");
909        assert_eq!(slugify_branch("main"), "main");
910        assert_eq!(slugify_branch("a/b/c"), "a-b-c");
911    }
912
913    #[test]
914    fn slugify_branch_lowercases() {
915        assert_eq!(slugify_branch("FEAT/X"), "feat-x");
916    }
917
918    #[test]
919    fn slugify_branch_empty_returns_agent() {
920        assert_eq!(slugify_branch(""), "agent");
921    }
922
923    #[test]
924    fn slugify_branch_only_dashes_returns_agent() {
925        assert_eq!(slugify_branch("---"), "agent");
926    }
927
928    #[test]
929    fn slugify_branch_collapses_consecutive_dashes() {
930        assert_eq!(slugify_branch("feat//x"), "feat-x");
931    }
932
933    #[test]
934    fn slugify_branch_trims_leading_trailing_dashes() {
935        assert_eq!(slugify_branch("/feat/x/"), "feat-x");
936    }
937
938    #[test]
939    fn agent_id_status() {
940        let msg = make_status("feat-x", "working");
941        assert_eq!(msg.agent_id(), "feat-x");
942    }
943
944    #[test]
945    fn agent_id_artifact() {
946        let msg = make_artifact("feat-y", "done", &["auth"]);
947        assert_eq!(msg.agent_id(), "feat-y");
948    }
949
950    #[test]
951    fn agent_id_blocked() {
952        let msg = make_blocked("feat-config", "error types", "feat-errors");
953        assert_eq!(msg.agent_id(), "feat-config");
954    }
955
956    #[test]
957    fn status_label_status_variant() {
958        let msg = make_status("feat-x", "working");
959        assert_eq!(msg.status_label(), "working");
960    }
961
962    #[test]
963    fn status_label_artifact_variant() {
964        let msg = make_artifact("feat-x", "done", &[]);
965        assert_eq!(msg.status_label(), "done");
966    }
967
968    #[test]
969    fn status_label_blocked_variant() {
970        let msg = make_blocked("feat-config", "error types", "feat-errors");
971        assert_eq!(msg.status_label(), "blocked");
972    }
973
974    #[test]
975    fn display_status() {
976        let msg = make_status("feat-x", "working");
977        assert_eq!(
978            msg.to_string(),
979            "[feat-x] status: working (0 files modified)"
980        );
981    }
982
983    #[test]
984    fn display_status_with_files() {
985        let msg = BrokerMessage::Status {
986            agent_id: "feat-x".to_string(),
987            payload: StatusPayload {
988                status: "working".to_string(),
989                modified_files: vec!["a.rs".to_string(), "b.rs".to_string()],
990                message: None,
991                ..Default::default()
992            },
993        };
994        assert_eq!(
995            msg.to_string(),
996            "[feat-x] status: working (2 files modified)"
997        );
998    }
999
1000    #[test]
1001    fn display_artifact_no_exports() {
1002        let msg = make_artifact("feat-x", "done", &[]);
1003        assert_eq!(msg.to_string(), "[feat-x] artifact: done");
1004    }
1005
1006    #[test]
1007    fn display_artifact_with_exports() {
1008        let msg = make_artifact("feat-x", "done", &["PawError", "Config"]);
1009        assert_eq!(
1010            msg.to_string(),
1011            "[feat-x] artifact: done \u{2014} exports: PawError, Config"
1012        );
1013    }
1014
1015    #[test]
1016    fn display_blocked() {
1017        let msg = make_blocked("feat-config", "error types", "feat-errors");
1018        assert_eq!(
1019            msg.to_string(),
1020            "[feat-config] blocked: needs error types from feat-errors"
1021        );
1022    }
1023
1024    #[test]
1025    fn from_json_valid_status() {
1026        let json = r#"{"type":"agent.status","agent_id":"feat-x","payload":{"status":"working","modified_files":[],"message":null}}"#;
1027        let msg = BrokerMessage::from_json(json).unwrap();
1028        assert_eq!(msg.agent_id(), "feat-x");
1029        assert_eq!(msg.status_label(), "working");
1030    }
1031
1032    #[test]
1033    fn from_json_empty_agent_id_rejected() {
1034        let json = r#"{"type":"agent.status","agent_id":"","payload":{"status":"working","modified_files":[]}}"#;
1035        let err = BrokerMessage::from_json(json).unwrap_err();
1036        assert!(matches!(err, MessageError::EmptyAgentId));
1037    }
1038
1039    #[test]
1040    fn from_json_accepts_slash_in_agent_id() {
1041        // `feat/<name>` is valid per the agent_id regex enforced at the HTTP
1042        // boundary; the deserialisation-layer validator no longer rejects it
1043        // on character grounds. The shape check happens in
1044        // `src/broker/server.rs::publish` against the canonical regex.
1045        let json = r#"{"type":"agent.status","agent_id":"feat/x","payload":{"status":"working","modified_files":[]}}"#;
1046        BrokerMessage::from_json(json).expect("feat/x deserialises cleanly");
1047    }
1048
1049    #[test]
1050    fn from_json_empty_status_rejected() {
1051        let json = r#"{"type":"agent.status","agent_id":"feat-x","payload":{"status":"","modified_files":[]}}"#;
1052        let err = BrokerMessage::from_json(json).unwrap_err();
1053        assert!(matches!(err, MessageError::EmptyStatusField));
1054    }
1055
1056    #[test]
1057    fn from_json_empty_artifact_status_rejected() {
1058        let json = r#"{"type":"agent.artifact","agent_id":"feat-x","payload":{"status":"","exports":[],"modified_files":[]}}"#;
1059        let err = BrokerMessage::from_json(json).unwrap_err();
1060        assert!(matches!(err, MessageError::EmptyStatusField));
1061    }
1062
1063    #[test]
1064    fn from_json_empty_needs_rejected() {
1065        let json = r#"{"type":"agent.blocked","agent_id":"feat-x","payload":{"needs":"","from":"feat-y"}}"#;
1066        let err = BrokerMessage::from_json(json).unwrap_err();
1067        assert!(matches!(err, MessageError::EmptyNeedsField));
1068    }
1069
1070    #[test]
1071    fn from_json_empty_from_rejected() {
1072        let json =
1073            r#"{"type":"agent.blocked","agent_id":"feat-x","payload":{"needs":"types","from":""}}"#;
1074        let err = BrokerMessage::from_json(json).unwrap_err();
1075        assert!(matches!(err, MessageError::EmptyFromField));
1076    }
1077
1078    #[test]
1079    fn from_json_invalid_json_rejected() {
1080        let err = BrokerMessage::from_json("not json").unwrap_err();
1081        assert!(matches!(err, MessageError::Deserialize(_)));
1082    }
1083
1084    #[test]
1085    fn serde_roundtrip_status() {
1086        let msg = make_status("feat-x", "working");
1087        let json = serde_json::to_string(&msg).unwrap();
1088        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
1089        assert_eq!(back.agent_id(), "feat-x");
1090        assert_eq!(back.status_label(), "working");
1091    }
1092
1093    // --- StatusPayload cli/phase fields (tasks 1.3-1.6) ---
1094
1095    #[test]
1096    fn status_payload_roundtrip_with_cli_and_phase() {
1097        let payload = StatusPayload {
1098            status: "working".to_string(),
1099            modified_files: vec!["src/a.rs".to_string()],
1100            message: Some("refactoring".to_string()),
1101            cli: Some("claude".to_string()),
1102            phase: Some("watching".to_string()),
1103            detail: None,
1104        };
1105        let json = serde_json::to_string(&payload).unwrap();
1106        assert!(json.contains("\"cli\":\"claude\""));
1107        assert!(json.contains("\"phase\":\"watching\""));
1108        let back: StatusPayload = serde_json::from_str(&json).unwrap();
1109        assert_eq!(back, payload);
1110    }
1111
1112    #[test]
1113    fn status_payload_deserialises_legacy_json_without_cli_or_phase() {
1114        let json = r#"{"status":"working","modified_files":[],"message":"Supervisor booting"}"#;
1115        let payload: StatusPayload = serde_json::from_str(json).unwrap();
1116        assert_eq!(payload.cli, None);
1117        assert_eq!(payload.phase, None);
1118        assert_eq!(payload.status, "working");
1119        assert_eq!(payload.message.as_deref(), Some("Supervisor booting"));
1120    }
1121
1122    #[test]
1123    fn status_payload_serialises_none_cli_and_phase_with_no_keys() {
1124        let payload = StatusPayload {
1125            status: "idle".to_string(),
1126            modified_files: vec![],
1127            message: None,
1128            cli: None,
1129            phase: None,
1130            detail: None,
1131        };
1132        let json = serde_json::to_string(&payload).unwrap();
1133        assert!(
1134            !json.contains("\"cli\""),
1135            "cli key must be omitted when None; got {json}"
1136        );
1137        assert!(
1138            !json.contains("\"phase\""),
1139            "phase key must be omitted when None; got {json}"
1140        );
1141    }
1142
1143    #[test]
1144    fn status_payload_deserialises_with_only_cli_populated() {
1145        let json = r#"{"status":"working","modified_files":[],"message":null,"cli":"claude"}"#;
1146        let payload: StatusPayload = serde_json::from_str(json).unwrap();
1147        assert_eq!(payload.cli.as_deref(), Some("claude"));
1148        assert_eq!(payload.phase, None);
1149    }
1150
1151    #[test]
1152    fn status_payload_deserialises_with_only_phase_populated() {
1153        let json = r#"{"status":"feedback","modified_files":[],"message":null,"phase":"merging"}"#;
1154        let payload: StatusPayload = serde_json::from_str(json).unwrap();
1155        assert_eq!(payload.phase.as_deref(), Some("merging"));
1156        assert_eq!(payload.cli, None);
1157    }
1158
1159    // --- supervisor-introspection: phase + detail fields (tasks 1.2-1.4) ---
1160
1161    #[test]
1162    fn status_payload_v050_shape_round_trips_byte_equivalent() {
1163        // GIVEN a v0.5.0-shape status with no phase/detail (and no cli).
1164        // WHEN it is deserialised and re-serialised THEN the JSON must be
1165        // byte-equivalent — no `phase`/`detail`/`cli` null keys appear.
1166        let json = r#"{"status":"working","modified_files":["src/a.rs"],"message":"booting"}"#;
1167        let payload: StatusPayload = serde_json::from_str(json).unwrap();
1168        assert_eq!(payload.phase, None);
1169        assert_eq!(payload.detail, None);
1170        let round_tripped = serde_json::to_string(&payload).unwrap();
1171        assert_eq!(
1172            round_tripped, json,
1173            "v0.5.0 payload must round-trip byte-equivalently; got {round_tripped}"
1174        );
1175    }
1176
1177    #[test]
1178    fn status_payload_round_trips_with_phase_and_detail() {
1179        // Status with phase = "audit" and a structured detail body
1180        // round-trips losslessly through serde.
1181        let payload = StatusPayload {
1182            status: "working".to_string(),
1183            modified_files: vec![],
1184            message: None,
1185            cli: None,
1186            phase: Some("audit".to_string()),
1187            detail: Some(serde_json::json!({
1188                "branch": "feat/x",
1189                "audit_step": "tests",
1190            })),
1191        };
1192        let json = serde_json::to_string(&payload).unwrap();
1193        assert!(json.contains("\"phase\":\"audit\""));
1194        assert!(json.contains("\"audit_step\":\"tests\""));
1195        let back: StatusPayload = serde_json::from_str(&json).unwrap();
1196        assert_eq!(back, payload);
1197        assert_eq!(
1198            back.detail.as_ref().unwrap()["branch"],
1199            serde_json::json!("feat/x")
1200        );
1201    }
1202
1203    #[test]
1204    fn status_payload_accepts_unknown_phase_value() {
1205        // An unknown phase string (not in the v0.6.0 taxonomy) is accepted —
1206        // the broker does not validate the set of phase values.
1207        let json = r#"{"type":"agent.status","agent_id":"supervisor","payload":{"status":"working","modified_files":[],"phase":"future_value_not_in_v0_6_0_taxonomy","detail":{"k":"v"}}}"#;
1208        let msg = BrokerMessage::from_json(json).expect("unknown phase accepted");
1209        match &msg {
1210            BrokerMessage::Status { payload, .. } => {
1211                assert_eq!(
1212                    payload.phase.as_deref(),
1213                    Some("future_value_not_in_v0_6_0_taxonomy")
1214                );
1215                assert!(payload.detail.is_some());
1216            }
1217            other => panic!("expected Status variant, got {other:?}"),
1218        }
1219    }
1220
1221    #[test]
1222    fn serde_roundtrip_artifact() {
1223        let msg = make_artifact("feat-x", "done", &["PawError"]);
1224        let json = serde_json::to_string(&msg).unwrap();
1225        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
1226        assert_eq!(back.agent_id(), "feat-x");
1227        assert_eq!(back.status_label(), "done");
1228    }
1229
1230    #[test]
1231    fn serde_roundtrip_blocked() {
1232        let msg = make_blocked("a", "types", "b");
1233        let json = serde_json::to_string(&msg).unwrap();
1234        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
1235        assert_eq!(back.agent_id(), "a");
1236        assert_eq!(back.status_label(), "blocked");
1237    }
1238
1239    #[test]
1240    fn from_json_whitespace_agent_id_rejected() {
1241        let json = r#"{"type":"agent.status","agent_id":"   ","payload":{"status":"working","modified_files":[],"message":null}}"#;
1242        assert!(BrokerMessage::from_json(json).is_err());
1243    }
1244
1245    #[test]
1246    fn slugify_branch_preserves_underscores() {
1247        assert_eq!(slugify_branch("feat/my_feature"), "feat-my_feature");
1248    }
1249
1250    #[test]
1251    fn slugify_branch_replaces_non_ascii() {
1252        let result = slugify_branch("feat/日本語");
1253        assert!(result.is_ascii());
1254        assert_eq!(result, "feat");
1255    }
1256
1257    fn make_verified(agent_id: &str, verified_by: &str, message: Option<&str>) -> BrokerMessage {
1258        BrokerMessage::Verified {
1259            agent_id: agent_id.to_string(),
1260            payload: VerifiedPayload {
1261                verified_by: verified_by.to_string(),
1262                message: message.map(str::to_string),
1263            },
1264        }
1265    }
1266
1267    fn make_feedback(agent_id: &str, from: &str, errors: &[&str]) -> BrokerMessage {
1268        BrokerMessage::Feedback {
1269            agent_id: agent_id.to_string(),
1270            payload: FeedbackPayload {
1271                from: from.to_string(),
1272                errors: errors.iter().map(|s| (*s).to_string()).collect(),
1273            },
1274        }
1275    }
1276
1277    #[test]
1278    fn serde_roundtrip_verified_with_message() {
1279        let msg = make_verified("feat-errors", "supervisor", Some("all 12 tests pass"));
1280        let json = serde_json::to_string(&msg).unwrap();
1281        assert!(json.contains("\"type\":\"agent.verified\""));
1282        assert!(json.contains("all 12 tests pass"));
1283        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
1284        assert_eq!(back, msg);
1285    }
1286
1287    #[test]
1288    fn serde_roundtrip_verified_without_message() {
1289        let msg = make_verified("feat-errors", "supervisor", None);
1290        let json = serde_json::to_string(&msg).unwrap();
1291        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
1292        assert_eq!(back, msg);
1293    }
1294
1295    #[test]
1296    fn serde_roundtrip_feedback() {
1297        let msg = make_feedback(
1298            "feat-errors",
1299            "supervisor",
1300            &["test failed", "missing doc comment"],
1301        );
1302        let json = serde_json::to_string(&msg).unwrap();
1303        assert!(json.contains("\"type\":\"agent.feedback\""));
1304        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
1305        assert_eq!(back, msg);
1306    }
1307
1308    #[test]
1309    fn from_json_empty_verified_by_rejected() {
1310        let json = r#"{"type":"agent.verified","agent_id":"feat-errors","payload":{"verified_by":"","message":null}}"#;
1311        let err = BrokerMessage::from_json(json).unwrap_err();
1312        assert!(matches!(err, MessageError::EmptyVerifiedBy));
1313    }
1314
1315    #[test]
1316    fn from_json_empty_feedback_from_rejected() {
1317        let json = r#"{"type":"agent.feedback","agent_id":"feat-errors","payload":{"from":"","errors":["e1"]}}"#;
1318        let err = BrokerMessage::from_json(json).unwrap_err();
1319        assert!(matches!(err, MessageError::EmptyFromField));
1320    }
1321
1322    #[test]
1323    fn from_json_empty_feedback_errors_rejected() {
1324        let json = r#"{"type":"agent.feedback","agent_id":"feat-errors","payload":{"from":"supervisor","errors":[]}}"#;
1325        let err = BrokerMessage::from_json(json).unwrap_err();
1326        assert!(matches!(err, MessageError::EmptyErrors));
1327    }
1328
1329    #[test]
1330    fn display_verified_without_message() {
1331        let msg = make_verified("feat-errors", "supervisor", None);
1332        assert_eq!(msg.to_string(), "[feat-errors] verified by supervisor");
1333    }
1334
1335    #[test]
1336    fn display_verified_with_message() {
1337        let msg = make_verified("feat-errors", "supervisor", Some("all tests pass"));
1338        assert_eq!(
1339            msg.to_string(),
1340            "[feat-errors] verified by supervisor \u{2014} all tests pass"
1341        );
1342    }
1343
1344    #[test]
1345    fn display_feedback_with_three_errors() {
1346        let msg = make_feedback("feat-errors", "supervisor", &["e1", "e2", "e3"]);
1347        assert_eq!(
1348            msg.to_string(),
1349            "[feat-errors] feedback from supervisor: 3 errors"
1350        );
1351    }
1352
1353    #[test]
1354    fn status_label_verified() {
1355        let msg = make_verified("feat-x", "supervisor", None);
1356        assert_eq!(msg.status_label(), "verified");
1357    }
1358
1359    #[test]
1360    fn status_label_feedback() {
1361        let msg = make_feedback("feat-x", "supervisor", &["e"]);
1362        assert_eq!(msg.status_label(), "feedback");
1363    }
1364
1365    #[test]
1366    fn agent_id_verified() {
1367        let msg = make_verified("feat-x", "supervisor", None);
1368        assert_eq!(msg.agent_id(), "feat-x");
1369    }
1370
1371    #[test]
1372    fn agent_id_feedback() {
1373        let msg = make_feedback("feat-x", "supervisor", &["e"]);
1374        assert_eq!(msg.agent_id(), "feat-x");
1375    }
1376
1377    fn make_question(agent_id: &str, question: &str) -> BrokerMessage {
1378        BrokerMessage::Question {
1379            agent_id: agent_id.to_string(),
1380            payload: QuestionPayload {
1381                question: question.to_string(),
1382            },
1383        }
1384    }
1385
1386    #[test]
1387    fn question_empty_field_rejected() {
1388        let json =
1389            r#"{"type":"agent.question","agent_id":"feat-config","payload":{"question":""}}"#;
1390        let err = BrokerMessage::from_json(json).unwrap_err();
1391        assert!(matches!(err, MessageError::EmptyQuestionField));
1392    }
1393
1394    #[test]
1395    fn serde_roundtrip_question() {
1396        let msg = make_question("feat-config", "Should I skip tests?");
1397        let json = serde_json::to_string(&msg).unwrap();
1398        assert!(json.contains("\"type\":\"agent.question\""));
1399        assert!(json.contains("\"agent_id\":\"feat-config\""));
1400        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
1401        assert_eq!(back, msg);
1402    }
1403
1404    #[test]
1405    fn display_question() {
1406        let msg = make_question("feat-config", "Should I add a config field?");
1407        let s = msg.to_string();
1408        assert_eq!(s, "[feat-config] question: Should I add a config field?");
1409        assert!(!s.contains('\n'));
1410    }
1411
1412    #[test]
1413    fn status_label_question() {
1414        let msg = make_question("feat-config", "anything?");
1415        assert_eq!(msg.status_label(), "question");
1416    }
1417
1418    #[test]
1419    fn agent_id_question() {
1420        let msg = make_question("feat-config", "anything?");
1421        assert_eq!(msg.agent_id(), "feat-config");
1422    }
1423
1424    #[test]
1425    fn question_whitespace_field_rejected() {
1426        let json =
1427            r#"{"type":"agent.question","agent_id":"feat-x","payload":{"question":"   \n\t  "}}"#;
1428        let err = BrokerMessage::from_json(json).unwrap_err();
1429        assert!(matches!(err, MessageError::EmptyQuestionField));
1430    }
1431
1432    #[test]
1433    fn question_empty_agent_id_rejected() {
1434        let json = r#"{"type":"agent.question","agent_id":"","payload":{"question":"why?"}}"#;
1435        let err = BrokerMessage::from_json(json).unwrap_err();
1436        assert!(matches!(err, MessageError::EmptyAgentId));
1437    }
1438
1439    #[test]
1440    fn from_json_valid_question() {
1441        let json = r#"{"type":"agent.question","agent_id":"feat-x","payload":{"question":"Should I merge feat-a before feat-b?"}}"#;
1442        let msg = BrokerMessage::from_json(json).unwrap();
1443        assert_eq!(msg.agent_id(), "feat-x");
1444        assert_eq!(msg.status_label(), "question");
1445        match &msg {
1446            BrokerMessage::Question { payload, .. } => {
1447                assert_eq!(payload.question, "Should I merge feat-a before feat-b?");
1448            }
1449            other => panic!("expected Question variant, got {other:?}"),
1450        }
1451    }
1452
1453    #[test]
1454    fn serde_roundtrip_question_feat_x() {
1455        let msg = make_question("feat-x", "Should I rebase?");
1456        let json = serde_json::to_string(&msg).unwrap();
1457        assert!(json.contains("\"type\":\"agent.question\""));
1458        assert!(json.contains("\"agent_id\":\"feat-x\""));
1459        assert!(json.contains("\"payload\""));
1460        assert!(json.contains("\"question\":\"Should I rebase?\""));
1461        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
1462        assert_eq!(back, msg);
1463    }
1464
1465    #[test]
1466    fn display_question_matches_spec_format() {
1467        let msg = make_question("supervisor", "Should I merge feat-a before feat-b?");
1468        let s = msg.to_string();
1469        assert_eq!(
1470            s,
1471            "[supervisor] question: Should I merge feat-a before feat-b?"
1472        );
1473        assert!(!s.contains('\n'), "display output must be a single line");
1474        // ANSI escape sequences start with the ESC character (0x1B).
1475        assert!(
1476            !s.contains('\u{1b}'),
1477            "display output must not contain ANSI escape sequences"
1478        );
1479    }
1480
1481    #[test]
1482    fn from_json_unknown_type_rejected() {
1483        let json = r#"{"type":"agent.unknown","agent_id":"x","payload":{}}"#;
1484        assert!(BrokerMessage::from_json(json).is_err());
1485    }
1486
1487    #[test]
1488    fn slugify_branch_deterministic() {
1489        let a = slugify_branch("feat/http-broker");
1490        let b = slugify_branch("feat/http-broker");
1491        assert_eq!(a, b);
1492    }
1493
1494    // --- Intent variant ---
1495
1496    fn make_intent(agent_id: &str, files: &[&str], summary: &str, ttl: u64) -> BrokerMessage {
1497        BrokerMessage::Intent {
1498            agent_id: agent_id.to_string(),
1499            payload: IntentPayload {
1500                files: files.iter().map(|s| FileIntent::from(*s)).collect(),
1501                summary: summary.to_string(),
1502                valid_for_seconds: ttl,
1503            },
1504        }
1505    }
1506
1507    #[test]
1508    fn intent_message_round_trips_through_serde() {
1509        let msg = make_intent("feat-auth", &["src/auth.rs"], "wire AuthClient", 900);
1510        let json = serde_json::to_string(&msg).unwrap();
1511        assert!(json.contains("\"type\":\"agent.intent\""));
1512        assert!(json.contains("\"agent_id\":\"feat-auth\""));
1513        assert!(json.contains("\"payload\""));
1514        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
1515        assert_eq!(back, msg);
1516    }
1517
1518    #[test]
1519    fn intent_payload_with_multiple_files_round_trips() {
1520        let msg = make_intent(
1521            "feat-auth",
1522            &["src/auth.rs", "src/auth/client.rs"],
1523            "wire AuthClient",
1524            900,
1525        );
1526        let json = serde_json::to_string(&msg).unwrap();
1527        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
1528        assert_eq!(back, msg);
1529        // files preserved in order
1530        if let BrokerMessage::Intent { payload, .. } = back {
1531            let paths: Vec<&str> = payload.files.iter().map(FileIntent::path).collect();
1532            assert_eq!(paths, vec!["src/auth.rs", "src/auth/client.rs"]);
1533        } else {
1534            panic!("expected Intent");
1535        }
1536    }
1537
1538    #[test]
1539    fn intent_payload_with_single_file_round_trips() {
1540        let msg = make_intent("feat-x", &["README.md"], "doc fix", 300);
1541        let json = serde_json::to_string(&msg).unwrap();
1542        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
1543        assert_eq!(back, msg);
1544    }
1545
1546    #[test]
1547    fn intent_empty_files_array_rejected() {
1548        let json = r#"{"type":"agent.intent","agent_id":"feat-x","payload":{"files":[],"summary":"x","valid_for_seconds":60}}"#;
1549        let err = BrokerMessage::from_json(json).unwrap_err();
1550        assert!(matches!(err, MessageError::EmptyIntentFiles));
1551    }
1552
1553    #[test]
1554    fn intent_whitespace_file_path_rejected() {
1555        let json = r#"{"type":"agent.intent","agent_id":"feat-x","payload":{"files":["   "],"summary":"x","valid_for_seconds":60}}"#;
1556        let err = BrokerMessage::from_json(json).unwrap_err();
1557        assert!(matches!(err, MessageError::EmptyIntentFileEntry));
1558    }
1559
1560    #[test]
1561    fn intent_empty_summary_rejected() {
1562        let json = r#"{"type":"agent.intent","agent_id":"feat-x","payload":{"files":["a"],"summary":"","valid_for_seconds":60}}"#;
1563        let err = BrokerMessage::from_json(json).unwrap_err();
1564        assert!(matches!(err, MessageError::EmptyIntentSummary));
1565    }
1566
1567    #[test]
1568    fn intent_zero_valid_for_seconds_rejected() {
1569        let json = r#"{"type":"agent.intent","agent_id":"feat-x","payload":{"files":["a"],"summary":"s","valid_for_seconds":0}}"#;
1570        let err = BrokerMessage::from_json(json).unwrap_err();
1571        assert!(matches!(err, MessageError::ZeroValidForSeconds));
1572    }
1573
1574    #[test]
1575    fn intent_valid_message_produces_broker_message() {
1576        let json = r#"{"type":"agent.intent","agent_id":"feat-auth","payload":{"files":["src/auth.rs"],"summary":"wire AuthClient","valid_for_seconds":900}}"#;
1577        let msg = BrokerMessage::from_json(json).unwrap();
1578        if let BrokerMessage::Intent { agent_id, payload } = msg {
1579            assert_eq!(agent_id, "feat-auth");
1580            assert_eq!(payload.files, vec![FileIntent::from("src/auth.rs")]);
1581            assert_eq!(payload.summary, "wire AuthClient");
1582            assert_eq!(payload.valid_for_seconds, 900);
1583        } else {
1584            panic!("expected Intent variant");
1585        }
1586    }
1587
1588    #[test]
1589    fn intent_display_output() {
1590        let msg = make_intent(
1591            "feat-auth",
1592            &["src/a.rs", "src/b.rs", "src/c.rs"],
1593            "wire AuthClient",
1594            900,
1595        );
1596        let s = msg.to_string();
1597        assert_eq!(
1598            s,
1599            "[feat-auth] intent: 3 files for 900s \u{2014} wire AuthClient"
1600        );
1601        assert!(!s.contains('\n'));
1602        assert!(!s.contains('\x1b'));
1603    }
1604
1605    #[test]
1606    fn intent_display_with_one_file() {
1607        let msg = make_intent("feat-x", &["README.md"], "doc fix", 300);
1608        assert_eq!(
1609            msg.to_string(),
1610            "[feat-x] intent: 1 files for 300s \u{2014} doc fix"
1611        );
1612    }
1613
1614    #[test]
1615    fn status_label_intent() {
1616        let msg = make_intent("feat-x", &["a"], "s", 60);
1617        assert_eq!(msg.status_label(), "intent");
1618    }
1619
1620    #[test]
1621    fn agent_id_intent() {
1622        let msg = make_intent("feat-auth", &["a"], "s", 60);
1623        assert_eq!(msg.agent_id(), "feat-auth");
1624    }
1625
1626    // Maps to scenario `Intent Display empty path edge` from
1627    // forward-coordination. Bypasses `from_json` (which would reject
1628    // `summary == ""` via MessageError::EmptyIntentSummary) and constructs
1629    // the variant directly so Display can be exercised on the empty case.
1630    // (test-coverage-v0-5-0 task 4.2)
1631    #[test]
1632    fn intent_display_with_empty_summary_renders_dash() {
1633        let msg = BrokerMessage::Intent {
1634            agent_id: "feat-x".to_string(),
1635            payload: IntentPayload {
1636                files: vec![FileIntent::from("src/a.rs")],
1637                summary: String::new(),
1638                valid_for_seconds: 60,
1639            },
1640        };
1641        let rendered = format!("{msg}");
1642        assert!(
1643            rendered.ends_with("\u{2014} "),
1644            "Display should end with em-dash + space when summary is empty; got: {rendered:?}"
1645        );
1646        assert!(
1647            rendered.starts_with("[feat-x] intent: 1 files for 60s "),
1648            "Display prefix should reflect file count and TTL; got: {rendered:?}"
1649        );
1650    }
1651
1652    // === FileIntent / Region wire shape (conflict-detector-fn-granularity
1653    //     tasks 1.4) ===
1654
1655    #[test]
1656    fn file_intent_string_entry_round_trips_to_bare_string() {
1657        // The v0.5.0 plain-string shape parses to a file-level intent and
1658        // serialises back to a bare JSON string (no object wrapper).
1659        let parsed: FileIntent = serde_json::from_str(r#""src/main.rs""#).unwrap();
1660        assert_eq!(parsed, FileIntent::Path("src/main.rs".to_string()));
1661        assert!(parsed.regions().is_none(), "string entry has no regions");
1662        assert_eq!(serde_json::to_string(&parsed).unwrap(), r#""src/main.rs""#);
1663    }
1664
1665    #[test]
1666    fn file_intent_object_entry_with_regions_round_trips() {
1667        let json =
1668            r#"{"path":"src/auth.rs","regions":[{"kind":"function","name":"validate_token"}]}"#;
1669        let parsed: FileIntent = serde_json::from_str(json).unwrap();
1670        assert_eq!(parsed.path(), "src/auth.rs");
1671        assert_eq!(
1672            parsed.regions(),
1673            Some(
1674                &vec![Region::Function {
1675                    name: "validate_token".to_string()
1676                }][..]
1677            )
1678        );
1679        // Re-serialises to the same object shape.
1680        assert_eq!(serde_json::to_string(&parsed).unwrap(), json);
1681    }
1682
1683    #[test]
1684    fn file_intent_object_entry_without_regions_omits_field() {
1685        // An object entry whose regions vec is empty serialises without the
1686        // `regions` key — equivalent on the wire to the plain string form.
1687        let entry = FileIntent::Detailed {
1688            path: "src/main.rs".to_string(),
1689            regions: vec![],
1690        };
1691        assert_eq!(
1692            serde_json::to_string(&entry).unwrap(),
1693            r#"{"path":"src/main.rs"}"#
1694        );
1695        // And `{"path": "..."}` parses back as a Detailed with no regions.
1696        let parsed: FileIntent = serde_json::from_str(r#"{"path":"src/main.rs"}"#).unwrap();
1697        assert_eq!(parsed.path(), "src/main.rs");
1698        assert!(parsed.regions().is_none());
1699    }
1700
1701    #[test]
1702    fn intent_mixed_string_and_object_files_round_trip() {
1703        let json = r#"{"type":"agent.intent","agent_id":"feat-x","payload":{"files":["src/main.rs",{"path":"src/auth.rs","regions":[{"kind":"function","name":"validate_token"}]}],"summary":"s","valid_for_seconds":60}}"#;
1704        let msg = BrokerMessage::from_json(json).unwrap();
1705        let BrokerMessage::Intent { payload, .. } = &msg else {
1706            panic!("expected Intent");
1707        };
1708        assert_eq!(payload.files.len(), 2);
1709        assert_eq!(
1710            payload.files[0],
1711            FileIntent::Path("src/main.rs".to_string())
1712        );
1713        assert_eq!(payload.files[1].path(), "src/auth.rs");
1714        assert_eq!(payload.files[1].regions().unwrap().len(), 1);
1715        // Round-trips byte-equivalently.
1716        assert_eq!(serde_json::to_string(&msg).unwrap(), json);
1717    }
1718
1719    #[test]
1720    fn region_each_kind_round_trips() {
1721        let cases = [
1722            (
1723                Region::Function {
1724                    name: "f".to_string(),
1725                },
1726                r#"{"kind":"function","name":"f"}"#,
1727            ),
1728            (
1729                Region::Class {
1730                    name: "C".to_string(),
1731                },
1732                r#"{"kind":"class","name":"C"}"#,
1733            ),
1734            (
1735                Region::Block {
1736                    anchor: "Setup".to_string(),
1737                },
1738                r#"{"kind":"block","anchor":"Setup"}"#,
1739            ),
1740            (
1741                Region::Range {
1742                    start_line: 10,
1743                    end_line: 50,
1744                },
1745                r#"{"kind":"range","start_line":10,"end_line":50}"#,
1746            ),
1747        ];
1748        for (region, expected_json) in cases {
1749            let json = serde_json::to_string(&region).unwrap();
1750            assert_eq!(json, expected_json);
1751            let back: Region = serde_json::from_str(&json).unwrap();
1752            assert_eq!(back, region);
1753        }
1754    }
1755
1756    #[test]
1757    fn region_unknown_kind_rejected_with_clear_error() {
1758        let json = r#"{"kind":"macro","name":"vec"}"#;
1759        let err = serde_json::from_str::<Region>(json).unwrap_err();
1760        let msg = err.to_string();
1761        assert!(
1762            msg.contains("macro"),
1763            "error should identify the offending kind `macro`; got: {msg}"
1764        );
1765    }
1766
1767    #[test]
1768    fn intent_with_unknown_region_kind_rejected() {
1769        let json = r#"{"type":"agent.intent","agent_id":"feat-x","payload":{"files":[{"path":"src/a.rs","regions":[{"kind":"macro","name":"vec"}]}],"summary":"s","valid_for_seconds":60}}"#;
1770        let err = BrokerMessage::from_json(json).unwrap_err();
1771        assert!(matches!(err, MessageError::Deserialize(_)));
1772    }
1773
1774    #[test]
1775    fn v050_string_only_intent_round_trips_byte_equivalent() {
1776        // Backwards compatibility: an intent published with the v0.5.0
1777        // string-only `files` shape must re-serialise byte-for-byte — no
1778        // object wrappers, no `regions` keys leak in.
1779        let json = r#"{"type":"agent.intent","agent_id":"feat-x","payload":{"files":["src/foo.rs","src/bar.rs"],"summary":"s","valid_for_seconds":900}}"#;
1780        let msg = BrokerMessage::from_json(json).unwrap();
1781        assert_eq!(
1782            serde_json::to_string(&msg).unwrap(),
1783            json,
1784            "v0.5.0 string-only intent must round-trip byte-equivalently"
1785        );
1786    }
1787
1788    #[test]
1789    fn region_display_renders_kind_and_name() {
1790        assert_eq!(
1791            Region::Function {
1792                name: "validate_token".to_string()
1793            }
1794            .to_string(),
1795            "function validate_token"
1796        );
1797        assert_eq!(
1798            Region::Class {
1799                name: "Auth".to_string()
1800            }
1801            .to_string(),
1802            "class Auth"
1803        );
1804        assert_eq!(
1805            Region::Block {
1806                anchor: "Setup".to_string()
1807            }
1808            .to_string(),
1809            "block Setup"
1810        );
1811        assert_eq!(
1812            Region::Range {
1813                start_line: 10,
1814                end_line: 30
1815            }
1816            .to_string(),
1817            "range 10-30"
1818        );
1819    }
1820
1821    // spec-corrections-v0-5-0 envelope + question coverage. The v0.5.0
1822    // wire format ships seven `BrokerMessage` variants, each tagged via
1823    // `#[serde(rename = "agent.<lowercase>")]`. The two tests below lock
1824    // the discriminator strings and the question-payload field shape so a
1825    // future serde rename can't drift the wire format.
1826
1827    #[test]
1828    #[allow(clippy::too_many_lines)] // exhaustive variant data table
1829    fn envelope_serde_rename_covers_seven_variants() {
1830        let variants = [
1831            (
1832                BrokerMessage::Status {
1833                    agent_id: "feat-a".to_string(),
1834                    payload: StatusPayload {
1835                        status: "working".to_string(),
1836                        modified_files: vec![],
1837                        message: None,
1838                        cli: None,
1839                        phase: None,
1840                        detail: None,
1841                    },
1842                },
1843                "agent.status",
1844            ),
1845            (
1846                BrokerMessage::Artifact {
1847                    agent_id: "feat-a".to_string(),
1848                    payload: ArtifactPayload {
1849                        status: "committed".to_string(),
1850                        exports: vec![],
1851                        modified_files: vec![],
1852                    },
1853                },
1854                "agent.artifact",
1855            ),
1856            (
1857                BrokerMessage::Blocked {
1858                    agent_id: "feat-a".to_string(),
1859                    payload: BlockedPayload {
1860                        needs: "auth token".to_string(),
1861                        from: "feat-b".to_string(),
1862                    },
1863                },
1864                "agent.blocked",
1865            ),
1866            (
1867                BrokerMessage::Verified {
1868                    agent_id: "feat-a".to_string(),
1869                    payload: VerifiedPayload {
1870                        verified_by: "supervisor".to_string(),
1871                        message: None,
1872                    },
1873                },
1874                "agent.verified",
1875            ),
1876            (
1877                BrokerMessage::Feedback {
1878                    agent_id: "feat-a".to_string(),
1879                    payload: FeedbackPayload {
1880                        from: "supervisor".to_string(),
1881                        errors: vec![],
1882                    },
1883                },
1884                "agent.feedback",
1885            ),
1886            (
1887                BrokerMessage::Question {
1888                    agent_id: "feat-a".to_string(),
1889                    payload: QuestionPayload {
1890                        question: "rs256 or hs256?".to_string(),
1891                    },
1892                },
1893                "agent.question",
1894            ),
1895            (
1896                BrokerMessage::Intent {
1897                    agent_id: "feat-a".to_string(),
1898                    payload: IntentPayload {
1899                        files: vec![FileIntent::from("src/a.rs")],
1900                        summary: "wire AuthClient".to_string(),
1901                        valid_for_seconds: 900,
1902                    },
1903                },
1904                "agent.intent",
1905            ),
1906            (
1907                BrokerMessage::AdvancedMain {
1908                    payload: AdvancedMainPayload {
1909                        from: "supervisor".to_string(),
1910                        merged_branch: "feat/auth".to_string(),
1911                        new_main_sha: "a1b2c3d4e5f6".to_string(),
1912                        base: "main".to_string(),
1913                        merged_at: DateTime::parse_from_rfc3339("2026-06-04T13:30:00Z")
1914                            .unwrap()
1915                            .with_timezone(&Utc),
1916                        summary: None,
1917                    },
1918                },
1919                "agent.advanced-main",
1920            ),
1921            (
1922                BrokerMessage::Learning {
1923                    payload: LearningPayload {
1924                        id: "deadbeefdeadbeef".to_string(),
1925                        agent_id: "supervisor".to_string(),
1926                        branch_id: Some("feat/x".to_string()),
1927                        category: "conflict_event".to_string(),
1928                        title: "forward conflict: feat-x and feat-y".to_string(),
1929                        body: serde_json::json!({"shape": "forward"}),
1930                        timestamp: "2026-05-28T12:01:01Z".to_string(),
1931                    },
1932                },
1933                "agent.learning",
1934            ),
1935            (
1936                BrokerMessage::VerifyNow {
1937                    branch_id: "feat/foo".to_string(),
1938                },
1939                "supervisor.verify-now",
1940            ),
1941        ];
1942
1943        // Sanity: assert we constructed ten distinct variants, matching the
1944        // spec'd count (nine `agent.*` — now including both `agent.advanced-main`
1945        // and `agent.learning` — plus the broker-emitted `supervisor.verify-now`
1946        // nudge).
1947        assert_eq!(
1948            variants.len(),
1949            10,
1950            "expected exactly ten BrokerMessage variants"
1951        );
1952
1953        for (msg, expected_tag) in &variants {
1954            let value = serde_json::to_value(msg).expect("serialise BrokerMessage");
1955            let obj = value.as_object().unwrap_or_else(|| {
1956                panic!("BrokerMessage must serialise as JSON object; got {value:?}")
1957            });
1958            let tag = obj
1959                .get("type")
1960                .and_then(|v| v.as_str())
1961                .unwrap_or_else(|| panic!("missing 'type' on {expected_tag} envelope"));
1962            assert_eq!(
1963                tag, *expected_tag,
1964                "wire discriminator drift: expected {expected_tag}, got {tag}",
1965            );
1966        }
1967    }
1968
1969    // === supervisor.verify-now nudge (per-commit-verification-v0-6-x) ===
1970
1971    #[test]
1972    fn verify_now_round_trips_with_branch_id() {
1973        let json = r#"{"type":"supervisor.verify-now","branch_id":"feat/foo"}"#;
1974        let msg = BrokerMessage::from_json(json).expect("verify-now must parse");
1975        let BrokerMessage::VerifyNow { branch_id } = &msg else {
1976            panic!("expected VerifyNow, got {msg:?}");
1977        };
1978        assert_eq!(branch_id, "feat/foo");
1979
1980        // Re-serialise and confirm the discriminator + field survive.
1981        let value = serde_json::to_value(&msg).expect("serialise VerifyNow");
1982        assert_eq!(
1983            value.get("type").and_then(|v| v.as_str()),
1984            Some("supervisor.verify-now")
1985        );
1986        assert_eq!(
1987            value.get("branch_id").and_then(|v| v.as_str()),
1988            Some("feat/foo")
1989        );
1990    }
1991
1992    #[test]
1993    fn verify_now_exposes_branch_as_agent_id_and_label() {
1994        let msg = BrokerMessage::VerifyNow {
1995            branch_id: "feat-bar".to_string(),
1996        };
1997        assert_eq!(msg.agent_id(), "feat-bar");
1998        assert_eq!(msg.status_label(), "verify-now");
1999    }
2000
2001    #[test]
2002    fn verify_now_rejects_blank_branch_id() {
2003        let json = r#"{"type":"supervisor.verify-now","branch_id":"   "}"#;
2004        assert!(
2005            matches!(
2006                BrokerMessage::from_json(json),
2007                Err(MessageError::EmptyAgentId)
2008            ),
2009            "blank branch_id must be rejected as an empty agent id"
2010        );
2011    }
2012
2013    // === agent.advanced-main variant (advanced-main-event) ===
2014
2015    fn sample_advanced_main_json() -> &'static str {
2016        r#"{"type":"agent.advanced-main","from":"supervisor","merged_branch":"feat/auth","new_main_sha":"a1b2c3d4e5f6","base":"main","merged_at":"2026-06-04T13:30:00Z","summary":"landed auth client"}"#
2017    }
2018
2019    #[test]
2020    fn advanced_main_round_trips_with_all_fields() {
2021        let msg = BrokerMessage::from_json(sample_advanced_main_json())
2022            .expect("well-formed advanced-main parses");
2023        let BrokerMessage::AdvancedMain { payload } = &msg else {
2024            panic!("expected AdvancedMain, got {msg:?}");
2025        };
2026        assert_eq!(payload.from, "supervisor");
2027        assert_eq!(payload.merged_branch, "feat/auth");
2028        assert_eq!(payload.new_main_sha, "a1b2c3d4e5f6");
2029        assert_eq!(payload.base, "main");
2030        assert_eq!(payload.summary.as_deref(), Some("landed auth client"));
2031
2032        // Re-serialise: the payload fields must be flat (top-level), not
2033        // nested under a `payload` key, and the discriminator must survive.
2034        let value = serde_json::to_value(&msg).expect("serialise AdvancedMain");
2035        assert_eq!(
2036            value.get("type").and_then(|v| v.as_str()),
2037            Some("agent.advanced-main")
2038        );
2039        assert_eq!(
2040            value.get("merged_branch").and_then(|v| v.as_str()),
2041            Some("feat/auth"),
2042            "merged_branch must be flattened to the envelope top level; got {value:?}"
2043        );
2044        assert!(
2045            value.get("payload").is_none(),
2046            "advanced-main fields must not nest under a `payload` key; got {value:?}"
2047        );
2048
2049        // Full round-trip equality.
2050        let back: BrokerMessage =
2051            serde_json::from_value(value).expect("deserialise re-serialised value");
2052        assert_eq!(back, msg);
2053    }
2054
2055    #[test]
2056    fn advanced_main_summary_omitted_when_absent() {
2057        let json = r#"{"type":"agent.advanced-main","from":"supervisor","merged_branch":"feat/x","new_main_sha":"deadbeefcafe","base":"main","merged_at":"2026-06-04T13:30:00Z"}"#;
2058        let msg = BrokerMessage::from_json(json).expect("parses without summary");
2059        let BrokerMessage::AdvancedMain { payload } = &msg else {
2060            panic!("expected AdvancedMain");
2061        };
2062        assert_eq!(payload.summary, None);
2063        // `summary` must be skipped on the wire when None.
2064        let serialised = serde_json::to_string(&msg).unwrap();
2065        assert!(
2066            !serialised.contains("summary"),
2067            "summary key must be omitted when None; got {serialised}"
2068        );
2069    }
2070
2071    #[test]
2072    fn advanced_main_preserves_summary_verbatim() {
2073        let msg = BrokerMessage::from_json(sample_advanced_main_json()).unwrap();
2074        if let BrokerMessage::AdvancedMain { payload } = &msg {
2075            assert_eq!(payload.summary.as_deref(), Some("landed auth client"));
2076        }
2077    }
2078
2079    // === agent.learning variant (agent-learning-variant) ===
2080
2081    fn make_learning(
2082        id: &str,
2083        agent_id: &str,
2084        branch_id: Option<&str>,
2085        category: &str,
2086        title: &str,
2087        body: serde_json::Value,
2088    ) -> BrokerMessage {
2089        BrokerMessage::Learning {
2090            payload: LearningPayload {
2091                id: id.to_string(),
2092                agent_id: agent_id.to_string(),
2093                branch_id: branch_id.map(str::to_string),
2094                category: category.to_string(),
2095                title: title.to_string(),
2096                body,
2097                timestamp: "2026-05-28T12:01:01Z".to_string(),
2098            },
2099        }
2100    }
2101
2102    #[test]
2103    fn advanced_main_missing_merged_branch_rejected() {
2104        // serde reports the absent required field by name -> 400-class.
2105        let json = r#"{"type":"agent.advanced-main","from":"supervisor","new_main_sha":"abc123abc123","base":"main","merged_at":"2026-06-04T13:30:00Z"}"#;
2106        let err = BrokerMessage::from_json(json).unwrap_err();
2107        let text = err.to_string();
2108        assert!(
2109            matches!(err, MessageError::Deserialize(_)) && text.contains("merged_branch"),
2110            "missing merged_branch must be rejected and named; got {text}"
2111        );
2112    }
2113
2114    #[test]
2115    fn advanced_main_missing_new_main_sha_rejected() {
2116        let json = r#"{"type":"agent.advanced-main","from":"supervisor","merged_branch":"feat/x","base":"main","merged_at":"2026-06-04T13:30:00Z"}"#;
2117        let err = BrokerMessage::from_json(json).unwrap_err();
2118        assert!(err.to_string().contains("new_main_sha"));
2119    }
2120
2121    #[test]
2122    fn advanced_main_missing_base_rejected() {
2123        let json = r#"{"type":"agent.advanced-main","from":"supervisor","merged_branch":"feat/x","new_main_sha":"abc123abc123","merged_at":"2026-06-04T13:30:00Z"}"#;
2124        let err = BrokerMessage::from_json(json).unwrap_err();
2125        assert!(err.to_string().contains("base"));
2126    }
2127
2128    #[test]
2129    fn advanced_main_missing_merged_at_rejected() {
2130        let json = r#"{"type":"agent.advanced-main","from":"supervisor","merged_branch":"feat/x","new_main_sha":"abc123abc123","base":"main"}"#;
2131        let err = BrokerMessage::from_json(json).unwrap_err();
2132        assert!(err.to_string().contains("merged_at"));
2133    }
2134
2135    #[test]
2136    fn advanced_main_blank_merged_branch_rejected() {
2137        let json = r#"{"type":"agent.advanced-main","from":"supervisor","merged_branch":"   ","new_main_sha":"abc123abc123","base":"main","merged_at":"2026-06-04T13:30:00Z"}"#;
2138        let err = BrokerMessage::from_json(json).unwrap_err();
2139        assert!(matches!(err, MessageError::EmptyMergedBranch));
2140    }
2141
2142    #[test]
2143    fn advanced_main_blank_new_main_sha_rejected() {
2144        let json = r#"{"type":"agent.advanced-main","from":"supervisor","merged_branch":"feat/x","new_main_sha":"","base":"main","merged_at":"2026-06-04T13:30:00Z"}"#;
2145        let err = BrokerMessage::from_json(json).unwrap_err();
2146        assert!(matches!(err, MessageError::EmptyNewMainSha));
2147    }
2148
2149    #[test]
2150    fn advanced_main_blank_base_rejected() {
2151        let json = r#"{"type":"agent.advanced-main","from":"supervisor","merged_branch":"feat/x","new_main_sha":"abc123abc123","base":"  ","merged_at":"2026-06-04T13:30:00Z"}"#;
2152        let err = BrokerMessage::from_json(json).unwrap_err();
2153        assert!(matches!(err, MessageError::EmptyBase));
2154    }
2155
2156    #[test]
2157    fn advanced_main_blank_from_rejected() {
2158        // `from` is the message's agent_id() -> caught by the empty-id guard.
2159        let json = r#"{"type":"agent.advanced-main","from":"   ","merged_branch":"feat/x","new_main_sha":"abc123abc123","base":"main","merged_at":"2026-06-04T13:30:00Z"}"#;
2160        let err = BrokerMessage::from_json(json).unwrap_err();
2161        assert!(matches!(err, MessageError::EmptyAgentId));
2162    }
2163
2164    #[test]
2165    fn learning_round_trips_through_serde() {
2166        let msg = make_learning(
2167            "abc123def456abcd",
2168            "supervisor",
2169            Some("feat/x"),
2170            "stuck_duration",
2171            "feat-x blocked 11m12s waiting on feat-y",
2172            serde_json::json!({
2173                "agent_id": "feat-x",
2174                "blocked_on": "feat-y",
2175                "duration_seconds": 672,
2176                "resolved": true
2177            }),
2178        );
2179        let json = serde_json::to_string(&msg).unwrap();
2180        assert!(json.contains("\"type\":\"agent.learning\""));
2181        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
2182        assert_eq!(back, msg);
2183        assert_eq!(back.agent_id(), "supervisor");
2184        assert_eq!(back.status_label(), "learning");
2185    }
2186
2187    #[test]
2188    fn learning_omits_branch_id_when_none() {
2189        let msg = make_learning(
2190            "abc123def456abcd",
2191            "supervisor",
2192            None,
2193            "permission_pattern",
2194            "`cargo check` auto-approved 23 times",
2195            serde_json::json!({"command_class": "cargo check", "count": 23}),
2196        );
2197        let json = serde_json::to_string(&msg).unwrap();
2198        assert!(
2199            !json.contains("branch_id"),
2200            "branch_id must be omitted when None; got {json}"
2201        );
2202        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
2203        if let BrokerMessage::Learning { payload } = back {
2204            assert_eq!(payload.branch_id, None);
2205        } else {
2206            panic!("expected Learning");
2207        }
2208    }
2209
2210    #[test]
2211    fn learning_missing_category_rejected_as_deserialize_error() {
2212        // `category` is a required serde field — its absence is a 400-class
2213        // deserialize error that names the missing field.
2214        let json = r#"{"type":"agent.learning","payload":{"id":"x","agent_id":"supervisor","title":"t","body":{},"timestamp":"2026-05-28T12:01:01Z"}}"#;
2215        let err = BrokerMessage::from_json(json).unwrap_err();
2216        assert!(matches!(err, MessageError::Deserialize(_)), "got {err:?}");
2217        assert!(err.to_string().contains("category"));
2218    }
2219
2220    #[test]
2221    fn learning_missing_body_rejected_as_deserialize_error() {
2222        let json = r#"{"type":"agent.learning","payload":{"id":"x","agent_id":"supervisor","category":"stuck_duration","title":"t","timestamp":"2026-05-28T12:01:01Z"}}"#;
2223        let err = BrokerMessage::from_json(json).unwrap_err();
2224        assert!(matches!(err, MessageError::Deserialize(_)), "got {err:?}");
2225        assert!(err.to_string().contains("body"));
2226    }
2227
2228    #[test]
2229    fn learning_empty_category_rejected() {
2230        let msg = make_learning("x", "supervisor", None, "  ", "t", serde_json::json!({}));
2231        let json = serde_json::to_string(&msg).unwrap();
2232        let err = BrokerMessage::from_json(&json).unwrap_err();
2233        assert!(matches!(err, MessageError::EmptyCategory));
2234    }
2235
2236    #[test]
2237    fn learning_empty_title_rejected() {
2238        let msg = make_learning(
2239            "x",
2240            "supervisor",
2241            None,
2242            "stuck_duration",
2243            "",
2244            serde_json::json!({}),
2245        );
2246        let json = serde_json::to_string(&msg).unwrap();
2247        let err = BrokerMessage::from_json(&json).unwrap_err();
2248        assert!(matches!(err, MessageError::EmptyTitle));
2249    }
2250
2251    #[test]
2252    fn learning_empty_timestamp_rejected() {
2253        let msg = BrokerMessage::Learning {
2254            payload: LearningPayload {
2255                id: "x".to_string(),
2256                agent_id: "supervisor".to_string(),
2257                branch_id: None,
2258                category: "stuck_duration".to_string(),
2259                title: "t".to_string(),
2260                body: serde_json::json!({}),
2261                timestamp: "   ".to_string(),
2262            },
2263        };
2264        let json = serde_json::to_string(&msg).unwrap();
2265        let err = BrokerMessage::from_json(&json).unwrap_err();
2266        assert!(matches!(err, MessageError::EmptyTimestamp));
2267    }
2268
2269    #[test]
2270    fn learning_empty_agent_id_rejected() {
2271        let msg = make_learning(
2272            "x",
2273            "",
2274            Some("feat/x"),
2275            "stuck_duration",
2276            "t",
2277            serde_json::json!({}),
2278        );
2279        let json = serde_json::to_string(&msg).unwrap();
2280        let err = BrokerMessage::from_json(&json).unwrap_err();
2281        assert!(matches!(err, MessageError::EmptyAgentId));
2282    }
2283
2284    #[test]
2285    fn advanced_main_agent_id_is_from_field() {
2286        let msg = BrokerMessage::from_json(sample_advanced_main_json()).unwrap();
2287        assert_eq!(msg.agent_id(), "supervisor");
2288        assert_eq!(msg.status_label(), "advanced-main");
2289    }
2290
2291    #[test]
2292    fn advanced_main_display_is_single_line() {
2293        let msg = BrokerMessage::from_json(sample_advanced_main_json()).unwrap();
2294        let s = msg.to_string();
2295        assert_eq!(
2296            s,
2297            "[supervisor] advanced-main: feat/auth \u{2192} main (a1b2c3d4e5f6)"
2298        );
2299        assert!(!s.contains('\n'));
2300        assert!(!s.contains('\u{1b}'));
2301    }
2302
2303    // --- Deterministic id (advanced-main-event §2) ---
2304
2305    fn ts(s: &str) -> DateTime<Utc> {
2306        DateTime::parse_from_rfc3339(s)
2307            .expect("valid rfc3339")
2308            .with_timezone(&Utc)
2309    }
2310
2311    #[test]
2312    fn advanced_main_id_is_16_hex_chars() {
2313        let id = advanced_main_id("feat/x", "abc123abc123", "main", ts("2026-06-04T13:30:00Z"));
2314        assert_eq!(id.len(), 16, "id must be a 16-hex-char (64-bit) prefix");
2315        assert!(
2316            id.chars()
2317                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
2318            "id must be lowercase hex; got {id}"
2319        );
2320    }
2321
2322    #[test]
2323    fn advanced_main_id_same_input_same_hour_is_identical() {
2324        let a = advanced_main_id("feat/x", "abc123abc123", "main", ts("2026-06-04T13:00:00Z"));
2325        let b = advanced_main_id("feat/x", "abc123abc123", "main", ts("2026-06-04T13:59:59Z"));
2326        assert_eq!(
2327            a, b,
2328            "same merge within the same UTC hour must dedup to one id"
2329        );
2330    }
2331
2332    #[test]
2333    fn advanced_main_id_differs_across_hour_boundary() {
2334        let a = advanced_main_id("feat/x", "abc123abc123", "main", ts("2026-06-04T13:59:59Z"));
2335        let b = advanced_main_id("feat/x", "abc123abc123", "main", ts("2026-06-04T14:00:00Z"));
2336        assert_ne!(
2337            a, b,
2338            "the same merge across an hour boundary must produce different ids"
2339        );
2340    }
2341
2342    #[test]
2343    fn advanced_main_id_differs_for_different_shas() {
2344        let a = advanced_main_id("feat/x", "aaaaaaaaaaaa", "main", ts("2026-06-04T13:30:00Z"));
2345        let b = advanced_main_id("feat/x", "bbbbbbbbbbbb", "main", ts("2026-06-04T13:30:00Z"));
2346        assert_ne!(a, b, "different SHAs must produce different ids");
2347    }
2348
2349    #[test]
2350    fn advanced_main_id_differs_for_different_base() {
2351        let a = advanced_main_id("feat/x", "abc123abc123", "main", ts("2026-06-04T13:30:00Z"));
2352        let b = advanced_main_id(
2353            "feat/x",
2354            "abc123abc123",
2355            "release",
2356            ts("2026-06-04T13:30:00Z"),
2357        );
2358        assert_ne!(a, b, "different base branches must produce different ids");
2359    }
2360
2361    #[test]
2362    fn advanced_main_payload_deterministic_id_matches_free_fn() {
2363        let payload = AdvancedMainPayload {
2364            from: "supervisor".to_string(),
2365            merged_branch: "feat/x".to_string(),
2366            new_main_sha: "abc123abc123".to_string(),
2367            base: "main".to_string(),
2368            merged_at: ts("2026-06-04T13:30:00Z"),
2369            summary: None,
2370        };
2371        assert_eq!(
2372            payload.deterministic_id(),
2373            advanced_main_id("feat/x", "abc123abc123", "main", ts("2026-06-04T13:30:00Z")),
2374        );
2375    }
2376
2377    #[test]
2378    fn learning_accepts_unknown_category_open_enum() {
2379        // A descendant change ([[qualitative-learnings]]) adds new category
2380        // values; the broker must accept them without an enum check.
2381        let msg = make_learning(
2382            "x",
2383            "supervisor",
2384            Some("feat/x"),
2385            "qualitative_insight",
2386            "agent kept re-reading the same file",
2387            serde_json::json!({"note": "thrash"}),
2388        );
2389        let json = serde_json::to_string(&msg).unwrap();
2390        let back = BrokerMessage::from_json(&json).expect("unknown category must be accepted");
2391        assert_eq!(back.agent_id(), "supervisor");
2392    }
2393
2394    #[test]
2395    fn question_payload_omits_from_field() {
2396        let payload = QuestionPayload {
2397            question: "what?".to_string(),
2398        };
2399        let value = serde_json::to_value(&payload).expect("serialise QuestionPayload");
2400        let obj = value
2401            .as_object()
2402            .expect("QuestionPayload must serialise as JSON object");
2403        assert!(
2404            !obj.contains_key("from"),
2405            "QuestionPayload must not have a 'from' field; got keys {:?}",
2406            obj.keys().collect::<Vec<_>>(),
2407        );
2408        // Sanity: the only documented field is `question`.
2409        assert!(
2410            obj.contains_key("question"),
2411            "QuestionPayload must serialise the 'question' field; got keys {:?}",
2412            obj.keys().collect::<Vec<_>>(),
2413        );
2414    }
2415}