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 serde::{Deserialize, Serialize};
9
10/// Validation errors for broker messages.
11#[derive(Debug, thiserror::Error)]
12pub enum MessageError {
13    /// The `agent_id` field is empty or whitespace-only.
14    #[error("agent_id must not be empty")]
15    EmptyAgentId,
16
17    /// The `status` field is empty or whitespace-only.
18    #[error("status field must not be empty")]
19    EmptyStatusField,
20
21    /// The `needs` field is empty or whitespace-only.
22    #[error("needs field must not be empty")]
23    EmptyNeedsField,
24
25    /// The `from` field is empty or whitespace-only.
26    #[error("from field must not be empty")]
27    EmptyFromField,
28
29    /// The `verified_by` field is empty or whitespace-only.
30    #[error("verified_by field must not be empty")]
31    EmptyVerifiedBy,
32
33    /// The `errors` list is empty.
34    #[error("errors list must not be empty")]
35    EmptyErrors,
36
37    /// The `question` field is empty or whitespace-only.
38    #[error("question field must not be empty")]
39    EmptyQuestionField,
40
41    /// The intent `files` array is empty.
42    #[error("intent files list must not be empty")]
43    EmptyIntentFiles,
44
45    /// An entry in the intent `files` array is empty or whitespace-only.
46    #[error("intent files entry must not be empty or whitespace-only")]
47    EmptyIntentFileEntry,
48
49    /// The intent `summary` field is empty or whitespace-only.
50    #[error("intent summary field must not be empty")]
51    EmptyIntentSummary,
52
53    /// The intent `valid_for_seconds` field is zero.
54    #[error("intent valid_for_seconds must be > 0")]
55    ZeroValidForSeconds,
56
57    /// JSON deserialization failed.
58    #[error("invalid message JSON: {0}")]
59    Deserialize(#[from] serde_json::Error),
60}
61
62/// Payload for `agent.status` messages.
63///
64/// `cli` and `phase` are optional and serialise with `skip_serializing_if =
65/// "Option::is_none"`, so legacy payloads without these fields deserialise as
66/// `None` and new payloads with `None` omit the field from the wire bytes.
67#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68pub struct StatusPayload {
69    /// Current status label (e.g. `"working"`, `"idle"`).
70    pub status: String,
71    /// List of files modified by the agent.
72    pub modified_files: Vec<String>,
73    /// Optional human-readable message.
74    pub message: Option<String>,
75    /// Optional CLI name (e.g. `"claude"`) identifying the CLI running in the
76    /// publishing agent's pane. The supervisor pane resolves this from
77    /// `[supervisor].cli` configuration; coding-agent panes typically omit
78    /// the field and rely on the broker's watch-target map.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub cli: Option<String>,
81    /// Optional free-form phase label (e.g. `"watching"`, `"merging"`) for
82    /// the publishing agent's current lifecycle phase. The dashboard prefers
83    /// this label over the message-type-derived `status_label()` when
84    /// rendering the agent's row.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub phase: Option<String>,
87}
88
89/// Payload for `agent.artifact` messages.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct ArtifactPayload {
92    /// Current status label (e.g. `"done"`).
93    pub status: String,
94    /// List of exported symbols or public API items.
95    pub exports: Vec<String>,
96    /// List of files modified by the agent.
97    pub modified_files: Vec<String>,
98}
99
100/// Payload for `agent.blocked` messages.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct BlockedPayload {
103    /// What the agent needs to proceed.
104    pub needs: String,
105    /// Agent ID of the agent that can unblock the sender.
106    pub from: String,
107}
108
109/// Payload for `agent.verified` messages.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct VerifiedPayload {
112    /// Agent ID of the verifier (typically `"supervisor"`).
113    pub verified_by: String,
114    /// Optional human-readable summary of the verification result.
115    pub message: Option<String>,
116}
117
118/// Payload for `agent.question` messages.
119///
120/// Wire format: `{"type": "agent.question", "agent_id": "<slug>", "payload": {"question": "<text>"}}`.
121/// The `question` field MUST NOT be empty. Question messages are routed to the
122/// `"supervisor"` inbox by the broker delivery layer.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124pub struct QuestionPayload {
125    /// The question text the agent is asking.
126    pub question: String,
127}
128
129/// Payload for `agent.intent` messages.
130///
131/// Wire format: `{"type": "agent.intent", "agent_id": "<slug>", "payload": {...}}`.
132/// `files` declares paths the agent plans to modify (relative to the repository
133/// root; globs are permitted but discouraged). `summary` is a one-line human
134/// description. `valid_for_seconds` is a relative TTL after which a consumer
135/// MAY treat the intent as stale.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub struct IntentPayload {
138    /// File paths the agent intends to modify.
139    pub files: Vec<String>,
140    /// One-line human description of the planned change.
141    pub summary: String,
142    /// Relative TTL in seconds (strictly positive).
143    pub valid_for_seconds: u64,
144}
145
146/// Payload for `agent.feedback` messages.
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148pub struct FeedbackPayload {
149    /// Agent ID of the sender (typically `"supervisor"`).
150    pub from: String,
151    /// List of error messages the target agent should address.
152    pub errors: Vec<String>,
153}
154
155/// Envelope for all inter-agent messages.
156///
157/// The wire format uses JSON with an internally tagged `"type"` discriminator
158/// whose values are `"agent.status"`, `"agent.artifact"`, `"agent.blocked"`,
159/// `"agent.verified"`, `"agent.feedback"`, `"agent.question"`, and
160/// `"agent.intent"`.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(tag = "type")]
163pub enum BrokerMessage {
164    /// Status heartbeat -- not routed to inboxes.
165    #[serde(rename = "agent.status")]
166    Status {
167        /// Sender agent ID (slugified branch name).
168        agent_id: String,
169        /// Status payload.
170        payload: StatusPayload,
171    },
172    /// Artifact announcement -- broadcast to all peers.
173    #[serde(rename = "agent.artifact")]
174    Artifact {
175        /// Sender agent ID.
176        agent_id: String,
177        /// Artifact payload.
178        payload: ArtifactPayload,
179    },
180    /// Blocked notification -- sent to the target agent.
181    #[serde(rename = "agent.blocked")]
182    Blocked {
183        /// Sender agent ID.
184        agent_id: String,
185        /// Blocked payload (contains `from` -- the unblocking agent).
186        payload: BlockedPayload,
187    },
188    /// Verification acknowledgement -- broadcast to all peers.
189    #[serde(rename = "agent.verified")]
190    Verified {
191        /// Target agent ID (the agent whose work was verified).
192        agent_id: String,
193        /// Verified payload (contains `verified_by` -- the sender).
194        payload: VerifiedPayload,
195    },
196    /// Feedback from a verifier -- delivered to the target agent only.
197    #[serde(rename = "agent.feedback")]
198    Feedback {
199        /// Target agent ID (the agent receiving feedback).
200        agent_id: String,
201        /// Feedback payload (contains `from` -- the sender).
202        payload: FeedbackPayload,
203    },
204    /// Agent question -- delivered to the `"supervisor"` inbox for human reply.
205    #[serde(rename = "agent.question")]
206    Question {
207        /// Sender agent ID (the agent asking the question).
208        agent_id: String,
209        /// Question payload.
210        payload: QuestionPayload,
211    },
212    /// Intent announcement -- broadcast to every other registered agent's inbox.
213    ///
214    /// Lets peers (and the broker conflict detector) see which files an
215    /// agent is about to modify before any commit lands.
216    #[serde(rename = "agent.intent")]
217    Intent {
218        /// Sender agent ID (the agent declaring the intent).
219        agent_id: String,
220        /// Intent payload.
221        payload: IntentPayload,
222    },
223}
224
225impl BrokerMessage {
226    /// Deserializes and validates a broker message from a JSON string.
227    ///
228    /// Returns [`MessageError`] if the JSON is malformed or the `agent_id` is
229    /// invalid.
230    pub fn from_json(input: &str) -> Result<Self, MessageError> {
231        let msg: Self = serde_json::from_str(input)?;
232        msg.validate()?;
233        Ok(msg)
234    }
235
236    /// Returns the `agent_id` field from whichever variant.
237    pub fn agent_id(&self) -> &str {
238        match self {
239            Self::Status { agent_id, .. }
240            | Self::Artifact { agent_id, .. }
241            | Self::Blocked { agent_id, .. }
242            | Self::Verified { agent_id, .. }
243            | Self::Feedback { agent_id, .. }
244            | Self::Question { agent_id, .. }
245            | Self::Intent { agent_id, .. } => agent_id,
246        }
247    }
248
249    /// Returns a short status label for the message.
250    ///
251    /// - `Status` returns `payload.status` (e.g. `"working"`)
252    /// - `Artifact` returns `payload.status` (e.g. `"done"`)
253    /// - `Blocked` returns `"blocked"`
254    /// - `Verified` returns `"verified"`
255    /// - `Feedback` returns `"feedback"`
256    /// - `Question` returns `"question"`
257    /// - `Intent` returns `"intent"`
258    pub fn status_label(&self) -> &str {
259        match self {
260            Self::Status { payload, .. } => &payload.status,
261            Self::Artifact { payload, .. } => &payload.status,
262            Self::Blocked { .. } => "blocked",
263            Self::Verified { .. } => "verified",
264            Self::Feedback { .. } => "feedback",
265            Self::Question { .. } => "question",
266            Self::Intent { .. } => "intent",
267        }
268    }
269
270    /// Validates all fields according to the broker message spec.
271    ///
272    /// The `agent_id` *shape* is enforced at the HTTP boundary by
273    /// `src/broker/server.rs::publish` against the canonical regex
274    /// `^(supervisor|feat/[a-z0-9][a-z0-9-]+|feat-[a-z0-9][a-z0-9-]+)$`
275    /// — this validator only catches the empty-or-whitespace case so
276    /// non-HTTP callers still trip a clear error on garbage input
277    /// before the typed value flows further.
278    fn validate(&self) -> Result<(), MessageError> {
279        let id = self.agent_id();
280        if id.trim().is_empty() {
281            return Err(MessageError::EmptyAgentId);
282        }
283        match self {
284            Self::Status { payload, .. } => {
285                if payload.status.trim().is_empty() {
286                    return Err(MessageError::EmptyStatusField);
287                }
288            }
289            Self::Artifact { payload, .. } => {
290                if payload.status.trim().is_empty() {
291                    return Err(MessageError::EmptyStatusField);
292                }
293            }
294            Self::Blocked { payload, .. } => {
295                if payload.needs.trim().is_empty() {
296                    return Err(MessageError::EmptyNeedsField);
297                }
298                if payload.from.trim().is_empty() {
299                    return Err(MessageError::EmptyFromField);
300                }
301            }
302            Self::Verified { payload, .. } => {
303                if payload.verified_by.trim().is_empty() {
304                    return Err(MessageError::EmptyVerifiedBy);
305                }
306            }
307            Self::Feedback { payload, .. } => {
308                if payload.from.trim().is_empty() {
309                    return Err(MessageError::EmptyFromField);
310                }
311                if payload.errors.is_empty() {
312                    return Err(MessageError::EmptyErrors);
313                }
314            }
315            Self::Question { payload, .. } => {
316                if payload.question.trim().is_empty() {
317                    return Err(MessageError::EmptyQuestionField);
318                }
319            }
320            Self::Intent { payload, .. } => {
321                if payload.files.is_empty() {
322                    return Err(MessageError::EmptyIntentFiles);
323                }
324                if payload.files.iter().any(|f| f.trim().is_empty()) {
325                    return Err(MessageError::EmptyIntentFileEntry);
326                }
327                if payload.summary.trim().is_empty() {
328                    return Err(MessageError::EmptyIntentSummary);
329                }
330                if payload.valid_for_seconds == 0 {
331                    return Err(MessageError::ZeroValidForSeconds);
332                }
333            }
334        }
335        Ok(())
336    }
337}
338
339impl fmt::Display for BrokerMessage {
340    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
341        match self {
342            Self::Status { agent_id, payload } => {
343                write!(
344                    f,
345                    "[{agent_id}] status: {} ({} files modified)",
346                    payload.status,
347                    payload.modified_files.len()
348                )
349            }
350            Self::Artifact {
351                agent_id, payload, ..
352            } => {
353                if payload.exports.is_empty() {
354                    write!(f, "[{agent_id}] artifact: {}", payload.status)
355                } else {
356                    write!(
357                        f,
358                        "[{agent_id}] artifact: {} \u{2014} exports: {}",
359                        payload.status,
360                        payload.exports.join(", ")
361                    )
362                }
363            }
364            Self::Blocked {
365                agent_id, payload, ..
366            } => {
367                write!(
368                    f,
369                    "[{agent_id}] blocked: needs {} from {}",
370                    payload.needs, payload.from
371                )
372            }
373            Self::Verified {
374                agent_id, payload, ..
375            } => {
376                if let Some(message) = &payload.message {
377                    write!(
378                        f,
379                        "[{agent_id}] verified by {} \u{2014} {message}",
380                        payload.verified_by
381                    )
382                } else {
383                    write!(f, "[{agent_id}] verified by {}", payload.verified_by)
384                }
385            }
386            Self::Feedback {
387                agent_id, payload, ..
388            } => {
389                write!(
390                    f,
391                    "[{agent_id}] feedback from {}: {} errors",
392                    payload.from,
393                    payload.errors.len()
394                )
395            }
396            Self::Question {
397                agent_id, payload, ..
398            } => {
399                write!(f, "[{agent_id}] question: {}", payload.question)
400            }
401            Self::Intent {
402                agent_id, payload, ..
403            } => {
404                write!(
405                    f,
406                    "[{agent_id}] intent: {} files for {}s \u{2014} {}",
407                    payload.files.len(),
408                    payload.valid_for_seconds,
409                    payload.summary,
410                )
411            }
412        }
413    }
414}
415
416/// Converts a git branch name into a stable broker `agent_id` slug.
417///
418/// Applies a 5-step normalization algorithm:
419///
420/// 1. Convert to ASCII lowercase
421/// 2. Replace any character not in `[a-z0-9_]` with `-`
422/// 3. Collapse consecutive `-` into a single `-`
423/// 4. Trim leading and trailing `-`
424/// 5. If the result is empty, return `"agent"`
425///
426/// # Examples
427///
428/// - `"feat/http-broker"` → `"feat-http-broker"`
429/// - `"a/b/c"` → `"a-b-c"`
430/// - `"FEAT/X"` → `"feat-x"`
431/// - `""` → `"agent"`
432/// - `"---"` → `"agent"`
433pub fn slugify_branch(name: &str) -> String {
434    // Step 1: to ASCII lowercase
435    let lowered = name.to_ascii_lowercase();
436
437    // Step 2: replace non-[a-z0-9_] with -
438    let replaced: String = lowered
439        .chars()
440        .map(|c| {
441            if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' {
442                c
443            } else {
444                '-'
445            }
446        })
447        .collect();
448
449    // Step 3: collapse consecutive - to single -
450    let mut collapsed = String::with_capacity(replaced.len());
451    let mut prev_dash = false;
452    for c in replaced.chars() {
453        if c == '-' {
454            if !prev_dash {
455                collapsed.push('-');
456            }
457            prev_dash = true;
458        } else {
459            collapsed.push(c);
460            prev_dash = false;
461        }
462    }
463
464    // Step 4: trim leading/trailing -
465    let trimmed = collapsed.trim_matches('-');
466
467    // Step 5: if empty, return "agent"
468    if trimmed.is_empty() {
469        "agent".to_string()
470    } else {
471        trimmed.to_string()
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    fn make_status(agent_id: &str, status: &str) -> BrokerMessage {
480        BrokerMessage::Status {
481            agent_id: agent_id.to_string(),
482            payload: StatusPayload {
483                status: status.to_string(),
484                modified_files: vec![],
485                message: None,
486                ..Default::default()
487            },
488        }
489    }
490
491    fn make_artifact(agent_id: &str, status: &str, exports: &[&str]) -> BrokerMessage {
492        BrokerMessage::Artifact {
493            agent_id: agent_id.to_string(),
494            payload: ArtifactPayload {
495                status: status.to_string(),
496                exports: exports.iter().map(|s| (*s).to_string()).collect(),
497                modified_files: vec!["src/main.rs".to_string()],
498            },
499        }
500    }
501
502    fn make_blocked(agent_id: &str, needs: &str, from: &str) -> BrokerMessage {
503        BrokerMessage::Blocked {
504            agent_id: agent_id.to_string(),
505            payload: BlockedPayload {
506                needs: needs.to_string(),
507                from: from.to_string(),
508            },
509        }
510    }
511
512    #[test]
513    fn slugify_branch_replaces_slashes() {
514        assert_eq!(slugify_branch("feat/errors"), "feat-errors");
515        assert_eq!(slugify_branch("main"), "main");
516        assert_eq!(slugify_branch("a/b/c"), "a-b-c");
517    }
518
519    #[test]
520    fn slugify_branch_lowercases() {
521        assert_eq!(slugify_branch("FEAT/X"), "feat-x");
522    }
523
524    #[test]
525    fn slugify_branch_empty_returns_agent() {
526        assert_eq!(slugify_branch(""), "agent");
527    }
528
529    #[test]
530    fn slugify_branch_only_dashes_returns_agent() {
531        assert_eq!(slugify_branch("---"), "agent");
532    }
533
534    #[test]
535    fn slugify_branch_collapses_consecutive_dashes() {
536        assert_eq!(slugify_branch("feat//x"), "feat-x");
537    }
538
539    #[test]
540    fn slugify_branch_trims_leading_trailing_dashes() {
541        assert_eq!(slugify_branch("/feat/x/"), "feat-x");
542    }
543
544    #[test]
545    fn agent_id_status() {
546        let msg = make_status("feat-x", "working");
547        assert_eq!(msg.agent_id(), "feat-x");
548    }
549
550    #[test]
551    fn agent_id_artifact() {
552        let msg = make_artifact("feat-y", "done", &["auth"]);
553        assert_eq!(msg.agent_id(), "feat-y");
554    }
555
556    #[test]
557    fn agent_id_blocked() {
558        let msg = make_blocked("feat-config", "error types", "feat-errors");
559        assert_eq!(msg.agent_id(), "feat-config");
560    }
561
562    #[test]
563    fn status_label_status_variant() {
564        let msg = make_status("feat-x", "working");
565        assert_eq!(msg.status_label(), "working");
566    }
567
568    #[test]
569    fn status_label_artifact_variant() {
570        let msg = make_artifact("feat-x", "done", &[]);
571        assert_eq!(msg.status_label(), "done");
572    }
573
574    #[test]
575    fn status_label_blocked_variant() {
576        let msg = make_blocked("feat-config", "error types", "feat-errors");
577        assert_eq!(msg.status_label(), "blocked");
578    }
579
580    #[test]
581    fn display_status() {
582        let msg = make_status("feat-x", "working");
583        assert_eq!(
584            msg.to_string(),
585            "[feat-x] status: working (0 files modified)"
586        );
587    }
588
589    #[test]
590    fn display_status_with_files() {
591        let msg = BrokerMessage::Status {
592            agent_id: "feat-x".to_string(),
593            payload: StatusPayload {
594                status: "working".to_string(),
595                modified_files: vec!["a.rs".to_string(), "b.rs".to_string()],
596                message: None,
597                ..Default::default()
598            },
599        };
600        assert_eq!(
601            msg.to_string(),
602            "[feat-x] status: working (2 files modified)"
603        );
604    }
605
606    #[test]
607    fn display_artifact_no_exports() {
608        let msg = make_artifact("feat-x", "done", &[]);
609        assert_eq!(msg.to_string(), "[feat-x] artifact: done");
610    }
611
612    #[test]
613    fn display_artifact_with_exports() {
614        let msg = make_artifact("feat-x", "done", &["PawError", "Config"]);
615        assert_eq!(
616            msg.to_string(),
617            "[feat-x] artifact: done \u{2014} exports: PawError, Config"
618        );
619    }
620
621    #[test]
622    fn display_blocked() {
623        let msg = make_blocked("feat-config", "error types", "feat-errors");
624        assert_eq!(
625            msg.to_string(),
626            "[feat-config] blocked: needs error types from feat-errors"
627        );
628    }
629
630    #[test]
631    fn from_json_valid_status() {
632        let json = r#"{"type":"agent.status","agent_id":"feat-x","payload":{"status":"working","modified_files":[],"message":null}}"#;
633        let msg = BrokerMessage::from_json(json).unwrap();
634        assert_eq!(msg.agent_id(), "feat-x");
635        assert_eq!(msg.status_label(), "working");
636    }
637
638    #[test]
639    fn from_json_empty_agent_id_rejected() {
640        let json = r#"{"type":"agent.status","agent_id":"","payload":{"status":"working","modified_files":[]}}"#;
641        let err = BrokerMessage::from_json(json).unwrap_err();
642        assert!(matches!(err, MessageError::EmptyAgentId));
643    }
644
645    #[test]
646    fn from_json_accepts_slash_in_agent_id() {
647        // `feat/<name>` is valid per the agent_id regex enforced at the HTTP
648        // boundary; the deserialisation-layer validator no longer rejects it
649        // on character grounds. The shape check happens in
650        // `src/broker/server.rs::publish` against the canonical regex.
651        let json = r#"{"type":"agent.status","agent_id":"feat/x","payload":{"status":"working","modified_files":[]}}"#;
652        BrokerMessage::from_json(json).expect("feat/x deserialises cleanly");
653    }
654
655    #[test]
656    fn from_json_empty_status_rejected() {
657        let json = r#"{"type":"agent.status","agent_id":"feat-x","payload":{"status":"","modified_files":[]}}"#;
658        let err = BrokerMessage::from_json(json).unwrap_err();
659        assert!(matches!(err, MessageError::EmptyStatusField));
660    }
661
662    #[test]
663    fn from_json_empty_artifact_status_rejected() {
664        let json = r#"{"type":"agent.artifact","agent_id":"feat-x","payload":{"status":"","exports":[],"modified_files":[]}}"#;
665        let err = BrokerMessage::from_json(json).unwrap_err();
666        assert!(matches!(err, MessageError::EmptyStatusField));
667    }
668
669    #[test]
670    fn from_json_empty_needs_rejected() {
671        let json = r#"{"type":"agent.blocked","agent_id":"feat-x","payload":{"needs":"","from":"feat-y"}}"#;
672        let err = BrokerMessage::from_json(json).unwrap_err();
673        assert!(matches!(err, MessageError::EmptyNeedsField));
674    }
675
676    #[test]
677    fn from_json_empty_from_rejected() {
678        let json =
679            r#"{"type":"agent.blocked","agent_id":"feat-x","payload":{"needs":"types","from":""}}"#;
680        let err = BrokerMessage::from_json(json).unwrap_err();
681        assert!(matches!(err, MessageError::EmptyFromField));
682    }
683
684    #[test]
685    fn from_json_invalid_json_rejected() {
686        let err = BrokerMessage::from_json("not json").unwrap_err();
687        assert!(matches!(err, MessageError::Deserialize(_)));
688    }
689
690    #[test]
691    fn serde_roundtrip_status() {
692        let msg = make_status("feat-x", "working");
693        let json = serde_json::to_string(&msg).unwrap();
694        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
695        assert_eq!(back.agent_id(), "feat-x");
696        assert_eq!(back.status_label(), "working");
697    }
698
699    // --- StatusPayload cli/phase fields (tasks 1.3-1.6) ---
700
701    #[test]
702    fn status_payload_roundtrip_with_cli_and_phase() {
703        let payload = StatusPayload {
704            status: "working".to_string(),
705            modified_files: vec!["src/a.rs".to_string()],
706            message: Some("refactoring".to_string()),
707            cli: Some("claude".to_string()),
708            phase: Some("watching".to_string()),
709        };
710        let json = serde_json::to_string(&payload).unwrap();
711        assert!(json.contains("\"cli\":\"claude\""));
712        assert!(json.contains("\"phase\":\"watching\""));
713        let back: StatusPayload = serde_json::from_str(&json).unwrap();
714        assert_eq!(back, payload);
715    }
716
717    #[test]
718    fn status_payload_deserialises_legacy_json_without_cli_or_phase() {
719        let json = r#"{"status":"working","modified_files":[],"message":"Supervisor booting"}"#;
720        let payload: StatusPayload = serde_json::from_str(json).unwrap();
721        assert_eq!(payload.cli, None);
722        assert_eq!(payload.phase, None);
723        assert_eq!(payload.status, "working");
724        assert_eq!(payload.message.as_deref(), Some("Supervisor booting"));
725    }
726
727    #[test]
728    fn status_payload_serialises_none_cli_and_phase_with_no_keys() {
729        let payload = StatusPayload {
730            status: "idle".to_string(),
731            modified_files: vec![],
732            message: None,
733            cli: None,
734            phase: None,
735        };
736        let json = serde_json::to_string(&payload).unwrap();
737        assert!(
738            !json.contains("\"cli\""),
739            "cli key must be omitted when None; got {json}"
740        );
741        assert!(
742            !json.contains("\"phase\""),
743            "phase key must be omitted when None; got {json}"
744        );
745    }
746
747    #[test]
748    fn status_payload_deserialises_with_only_cli_populated() {
749        let json = r#"{"status":"working","modified_files":[],"message":null,"cli":"claude"}"#;
750        let payload: StatusPayload = serde_json::from_str(json).unwrap();
751        assert_eq!(payload.cli.as_deref(), Some("claude"));
752        assert_eq!(payload.phase, None);
753    }
754
755    #[test]
756    fn status_payload_deserialises_with_only_phase_populated() {
757        let json = r#"{"status":"feedback","modified_files":[],"message":null,"phase":"merging"}"#;
758        let payload: StatusPayload = serde_json::from_str(json).unwrap();
759        assert_eq!(payload.phase.as_deref(), Some("merging"));
760        assert_eq!(payload.cli, None);
761    }
762
763    #[test]
764    fn serde_roundtrip_artifact() {
765        let msg = make_artifact("feat-x", "done", &["PawError"]);
766        let json = serde_json::to_string(&msg).unwrap();
767        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
768        assert_eq!(back.agent_id(), "feat-x");
769        assert_eq!(back.status_label(), "done");
770    }
771
772    #[test]
773    fn serde_roundtrip_blocked() {
774        let msg = make_blocked("a", "types", "b");
775        let json = serde_json::to_string(&msg).unwrap();
776        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
777        assert_eq!(back.agent_id(), "a");
778        assert_eq!(back.status_label(), "blocked");
779    }
780
781    #[test]
782    fn from_json_whitespace_agent_id_rejected() {
783        let json = r#"{"type":"agent.status","agent_id":"   ","payload":{"status":"working","modified_files":[],"message":null}}"#;
784        assert!(BrokerMessage::from_json(json).is_err());
785    }
786
787    #[test]
788    fn slugify_branch_preserves_underscores() {
789        assert_eq!(slugify_branch("feat/my_feature"), "feat-my_feature");
790    }
791
792    #[test]
793    fn slugify_branch_replaces_non_ascii() {
794        let result = slugify_branch("feat/日本語");
795        assert!(result.is_ascii());
796        assert_eq!(result, "feat");
797    }
798
799    fn make_verified(agent_id: &str, verified_by: &str, message: Option<&str>) -> BrokerMessage {
800        BrokerMessage::Verified {
801            agent_id: agent_id.to_string(),
802            payload: VerifiedPayload {
803                verified_by: verified_by.to_string(),
804                message: message.map(str::to_string),
805            },
806        }
807    }
808
809    fn make_feedback(agent_id: &str, from: &str, errors: &[&str]) -> BrokerMessage {
810        BrokerMessage::Feedback {
811            agent_id: agent_id.to_string(),
812            payload: FeedbackPayload {
813                from: from.to_string(),
814                errors: errors.iter().map(|s| (*s).to_string()).collect(),
815            },
816        }
817    }
818
819    #[test]
820    fn serde_roundtrip_verified_with_message() {
821        let msg = make_verified("feat-errors", "supervisor", Some("all 12 tests pass"));
822        let json = serde_json::to_string(&msg).unwrap();
823        assert!(json.contains("\"type\":\"agent.verified\""));
824        assert!(json.contains("all 12 tests pass"));
825        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
826        assert_eq!(back, msg);
827    }
828
829    #[test]
830    fn serde_roundtrip_verified_without_message() {
831        let msg = make_verified("feat-errors", "supervisor", None);
832        let json = serde_json::to_string(&msg).unwrap();
833        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
834        assert_eq!(back, msg);
835    }
836
837    #[test]
838    fn serde_roundtrip_feedback() {
839        let msg = make_feedback(
840            "feat-errors",
841            "supervisor",
842            &["test failed", "missing doc comment"],
843        );
844        let json = serde_json::to_string(&msg).unwrap();
845        assert!(json.contains("\"type\":\"agent.feedback\""));
846        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
847        assert_eq!(back, msg);
848    }
849
850    #[test]
851    fn from_json_empty_verified_by_rejected() {
852        let json = r#"{"type":"agent.verified","agent_id":"feat-errors","payload":{"verified_by":"","message":null}}"#;
853        let err = BrokerMessage::from_json(json).unwrap_err();
854        assert!(matches!(err, MessageError::EmptyVerifiedBy));
855    }
856
857    #[test]
858    fn from_json_empty_feedback_from_rejected() {
859        let json = r#"{"type":"agent.feedback","agent_id":"feat-errors","payload":{"from":"","errors":["e1"]}}"#;
860        let err = BrokerMessage::from_json(json).unwrap_err();
861        assert!(matches!(err, MessageError::EmptyFromField));
862    }
863
864    #[test]
865    fn from_json_empty_feedback_errors_rejected() {
866        let json = r#"{"type":"agent.feedback","agent_id":"feat-errors","payload":{"from":"supervisor","errors":[]}}"#;
867        let err = BrokerMessage::from_json(json).unwrap_err();
868        assert!(matches!(err, MessageError::EmptyErrors));
869    }
870
871    #[test]
872    fn display_verified_without_message() {
873        let msg = make_verified("feat-errors", "supervisor", None);
874        assert_eq!(msg.to_string(), "[feat-errors] verified by supervisor");
875    }
876
877    #[test]
878    fn display_verified_with_message() {
879        let msg = make_verified("feat-errors", "supervisor", Some("all tests pass"));
880        assert_eq!(
881            msg.to_string(),
882            "[feat-errors] verified by supervisor \u{2014} all tests pass"
883        );
884    }
885
886    #[test]
887    fn display_feedback_with_three_errors() {
888        let msg = make_feedback("feat-errors", "supervisor", &["e1", "e2", "e3"]);
889        assert_eq!(
890            msg.to_string(),
891            "[feat-errors] feedback from supervisor: 3 errors"
892        );
893    }
894
895    #[test]
896    fn status_label_verified() {
897        let msg = make_verified("feat-x", "supervisor", None);
898        assert_eq!(msg.status_label(), "verified");
899    }
900
901    #[test]
902    fn status_label_feedback() {
903        let msg = make_feedback("feat-x", "supervisor", &["e"]);
904        assert_eq!(msg.status_label(), "feedback");
905    }
906
907    #[test]
908    fn agent_id_verified() {
909        let msg = make_verified("feat-x", "supervisor", None);
910        assert_eq!(msg.agent_id(), "feat-x");
911    }
912
913    #[test]
914    fn agent_id_feedback() {
915        let msg = make_feedback("feat-x", "supervisor", &["e"]);
916        assert_eq!(msg.agent_id(), "feat-x");
917    }
918
919    fn make_question(agent_id: &str, question: &str) -> BrokerMessage {
920        BrokerMessage::Question {
921            agent_id: agent_id.to_string(),
922            payload: QuestionPayload {
923                question: question.to_string(),
924            },
925        }
926    }
927
928    #[test]
929    fn question_empty_field_rejected() {
930        let json =
931            r#"{"type":"agent.question","agent_id":"feat-config","payload":{"question":""}}"#;
932        let err = BrokerMessage::from_json(json).unwrap_err();
933        assert!(matches!(err, MessageError::EmptyQuestionField));
934    }
935
936    #[test]
937    fn serde_roundtrip_question() {
938        let msg = make_question("feat-config", "Should I skip tests?");
939        let json = serde_json::to_string(&msg).unwrap();
940        assert!(json.contains("\"type\":\"agent.question\""));
941        assert!(json.contains("\"agent_id\":\"feat-config\""));
942        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
943        assert_eq!(back, msg);
944    }
945
946    #[test]
947    fn display_question() {
948        let msg = make_question("feat-config", "Should I add a config field?");
949        let s = msg.to_string();
950        assert_eq!(s, "[feat-config] question: Should I add a config field?");
951        assert!(!s.contains('\n'));
952    }
953
954    #[test]
955    fn status_label_question() {
956        let msg = make_question("feat-config", "anything?");
957        assert_eq!(msg.status_label(), "question");
958    }
959
960    #[test]
961    fn agent_id_question() {
962        let msg = make_question("feat-config", "anything?");
963        assert_eq!(msg.agent_id(), "feat-config");
964    }
965
966    #[test]
967    fn question_whitespace_field_rejected() {
968        let json =
969            r#"{"type":"agent.question","agent_id":"feat-x","payload":{"question":"   \n\t  "}}"#;
970        let err = BrokerMessage::from_json(json).unwrap_err();
971        assert!(matches!(err, MessageError::EmptyQuestionField));
972    }
973
974    #[test]
975    fn question_empty_agent_id_rejected() {
976        let json = r#"{"type":"agent.question","agent_id":"","payload":{"question":"why?"}}"#;
977        let err = BrokerMessage::from_json(json).unwrap_err();
978        assert!(matches!(err, MessageError::EmptyAgentId));
979    }
980
981    #[test]
982    fn from_json_valid_question() {
983        let json = r#"{"type":"agent.question","agent_id":"feat-x","payload":{"question":"Should I merge feat-a before feat-b?"}}"#;
984        let msg = BrokerMessage::from_json(json).unwrap();
985        assert_eq!(msg.agent_id(), "feat-x");
986        assert_eq!(msg.status_label(), "question");
987        match &msg {
988            BrokerMessage::Question { payload, .. } => {
989                assert_eq!(payload.question, "Should I merge feat-a before feat-b?");
990            }
991            other => panic!("expected Question variant, got {other:?}"),
992        }
993    }
994
995    #[test]
996    fn serde_roundtrip_question_feat_x() {
997        let msg = make_question("feat-x", "Should I rebase?");
998        let json = serde_json::to_string(&msg).unwrap();
999        assert!(json.contains("\"type\":\"agent.question\""));
1000        assert!(json.contains("\"agent_id\":\"feat-x\""));
1001        assert!(json.contains("\"payload\""));
1002        assert!(json.contains("\"question\":\"Should I rebase?\""));
1003        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
1004        assert_eq!(back, msg);
1005    }
1006
1007    #[test]
1008    fn display_question_matches_spec_format() {
1009        let msg = make_question("supervisor", "Should I merge feat-a before feat-b?");
1010        let s = msg.to_string();
1011        assert_eq!(
1012            s,
1013            "[supervisor] question: Should I merge feat-a before feat-b?"
1014        );
1015        assert!(!s.contains('\n'), "display output must be a single line");
1016        // ANSI escape sequences start with the ESC character (0x1B).
1017        assert!(
1018            !s.contains('\u{1b}'),
1019            "display output must not contain ANSI escape sequences"
1020        );
1021    }
1022
1023    #[test]
1024    fn from_json_unknown_type_rejected() {
1025        let json = r#"{"type":"agent.unknown","agent_id":"x","payload":{}}"#;
1026        assert!(BrokerMessage::from_json(json).is_err());
1027    }
1028
1029    #[test]
1030    fn slugify_branch_deterministic() {
1031        let a = slugify_branch("feat/http-broker");
1032        let b = slugify_branch("feat/http-broker");
1033        assert_eq!(a, b);
1034    }
1035
1036    // --- Intent variant ---
1037
1038    fn make_intent(agent_id: &str, files: &[&str], summary: &str, ttl: u64) -> BrokerMessage {
1039        BrokerMessage::Intent {
1040            agent_id: agent_id.to_string(),
1041            payload: IntentPayload {
1042                files: files.iter().map(|s| (*s).to_string()).collect(),
1043                summary: summary.to_string(),
1044                valid_for_seconds: ttl,
1045            },
1046        }
1047    }
1048
1049    #[test]
1050    fn intent_message_round_trips_through_serde() {
1051        let msg = make_intent("feat-auth", &["src/auth.rs"], "wire AuthClient", 900);
1052        let json = serde_json::to_string(&msg).unwrap();
1053        assert!(json.contains("\"type\":\"agent.intent\""));
1054        assert!(json.contains("\"agent_id\":\"feat-auth\""));
1055        assert!(json.contains("\"payload\""));
1056        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
1057        assert_eq!(back, msg);
1058    }
1059
1060    #[test]
1061    fn intent_payload_with_multiple_files_round_trips() {
1062        let msg = make_intent(
1063            "feat-auth",
1064            &["src/auth.rs", "src/auth/client.rs"],
1065            "wire AuthClient",
1066            900,
1067        );
1068        let json = serde_json::to_string(&msg).unwrap();
1069        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
1070        assert_eq!(back, msg);
1071        // files preserved in order
1072        if let BrokerMessage::Intent { payload, .. } = back {
1073            assert_eq!(payload.files, vec!["src/auth.rs", "src/auth/client.rs"]);
1074        } else {
1075            panic!("expected Intent");
1076        }
1077    }
1078
1079    #[test]
1080    fn intent_payload_with_single_file_round_trips() {
1081        let msg = make_intent("feat-x", &["README.md"], "doc fix", 300);
1082        let json = serde_json::to_string(&msg).unwrap();
1083        let back: BrokerMessage = serde_json::from_str(&json).unwrap();
1084        assert_eq!(back, msg);
1085    }
1086
1087    #[test]
1088    fn intent_empty_files_array_rejected() {
1089        let json = r#"{"type":"agent.intent","agent_id":"feat-x","payload":{"files":[],"summary":"x","valid_for_seconds":60}}"#;
1090        let err = BrokerMessage::from_json(json).unwrap_err();
1091        assert!(matches!(err, MessageError::EmptyIntentFiles));
1092    }
1093
1094    #[test]
1095    fn intent_whitespace_file_path_rejected() {
1096        let json = r#"{"type":"agent.intent","agent_id":"feat-x","payload":{"files":["   "],"summary":"x","valid_for_seconds":60}}"#;
1097        let err = BrokerMessage::from_json(json).unwrap_err();
1098        assert!(matches!(err, MessageError::EmptyIntentFileEntry));
1099    }
1100
1101    #[test]
1102    fn intent_empty_summary_rejected() {
1103        let json = r#"{"type":"agent.intent","agent_id":"feat-x","payload":{"files":["a"],"summary":"","valid_for_seconds":60}}"#;
1104        let err = BrokerMessage::from_json(json).unwrap_err();
1105        assert!(matches!(err, MessageError::EmptyIntentSummary));
1106    }
1107
1108    #[test]
1109    fn intent_zero_valid_for_seconds_rejected() {
1110        let json = r#"{"type":"agent.intent","agent_id":"feat-x","payload":{"files":["a"],"summary":"s","valid_for_seconds":0}}"#;
1111        let err = BrokerMessage::from_json(json).unwrap_err();
1112        assert!(matches!(err, MessageError::ZeroValidForSeconds));
1113    }
1114
1115    #[test]
1116    fn intent_valid_message_produces_broker_message() {
1117        let json = r#"{"type":"agent.intent","agent_id":"feat-auth","payload":{"files":["src/auth.rs"],"summary":"wire AuthClient","valid_for_seconds":900}}"#;
1118        let msg = BrokerMessage::from_json(json).unwrap();
1119        if let BrokerMessage::Intent { agent_id, payload } = msg {
1120            assert_eq!(agent_id, "feat-auth");
1121            assert_eq!(payload.files, vec!["src/auth.rs"]);
1122            assert_eq!(payload.summary, "wire AuthClient");
1123            assert_eq!(payload.valid_for_seconds, 900);
1124        } else {
1125            panic!("expected Intent variant");
1126        }
1127    }
1128
1129    #[test]
1130    fn intent_display_output() {
1131        let msg = make_intent(
1132            "feat-auth",
1133            &["src/a.rs", "src/b.rs", "src/c.rs"],
1134            "wire AuthClient",
1135            900,
1136        );
1137        let s = msg.to_string();
1138        assert_eq!(
1139            s,
1140            "[feat-auth] intent: 3 files for 900s \u{2014} wire AuthClient"
1141        );
1142        assert!(!s.contains('\n'));
1143        assert!(!s.contains('\x1b'));
1144    }
1145
1146    #[test]
1147    fn intent_display_with_one_file() {
1148        let msg = make_intent("feat-x", &["README.md"], "doc fix", 300);
1149        assert_eq!(
1150            msg.to_string(),
1151            "[feat-x] intent: 1 files for 300s \u{2014} doc fix"
1152        );
1153    }
1154
1155    #[test]
1156    fn status_label_intent() {
1157        let msg = make_intent("feat-x", &["a"], "s", 60);
1158        assert_eq!(msg.status_label(), "intent");
1159    }
1160
1161    #[test]
1162    fn agent_id_intent() {
1163        let msg = make_intent("feat-auth", &["a"], "s", 60);
1164        assert_eq!(msg.agent_id(), "feat-auth");
1165    }
1166
1167    // Maps to scenario `Intent Display empty path edge` from
1168    // forward-coordination. Bypasses `from_json` (which would reject
1169    // `summary == ""` via MessageError::EmptyIntentSummary) and constructs
1170    // the variant directly so Display can be exercised on the empty case.
1171    // (test-coverage-v0-5-0 task 4.2)
1172    #[test]
1173    fn intent_display_with_empty_summary_renders_dash() {
1174        let msg = BrokerMessage::Intent {
1175            agent_id: "feat-x".to_string(),
1176            payload: IntentPayload {
1177                files: vec!["src/a.rs".to_string()],
1178                summary: String::new(),
1179                valid_for_seconds: 60,
1180            },
1181        };
1182        let rendered = format!("{msg}");
1183        assert!(
1184            rendered.ends_with("\u{2014} "),
1185            "Display should end with em-dash + space when summary is empty; got: {rendered:?}"
1186        );
1187        assert!(
1188            rendered.starts_with("[feat-x] intent: 1 files for 60s "),
1189            "Display prefix should reflect file count and TTL; got: {rendered:?}"
1190        );
1191    }
1192
1193    // spec-corrections-v0-5-0 envelope + question coverage. The v0.5.0
1194    // wire format ships seven `BrokerMessage` variants, each tagged via
1195    // `#[serde(rename = "agent.<lowercase>")]`. The two tests below lock
1196    // the discriminator strings and the question-payload field shape so a
1197    // future serde rename can't drift the wire format.
1198
1199    #[test]
1200    fn envelope_serde_rename_covers_seven_variants() {
1201        let variants = [
1202            (
1203                BrokerMessage::Status {
1204                    agent_id: "feat-a".to_string(),
1205                    payload: StatusPayload {
1206                        status: "working".to_string(),
1207                        modified_files: vec![],
1208                        message: None,
1209                        cli: None,
1210                        phase: None,
1211                    },
1212                },
1213                "agent.status",
1214            ),
1215            (
1216                BrokerMessage::Artifact {
1217                    agent_id: "feat-a".to_string(),
1218                    payload: ArtifactPayload {
1219                        status: "committed".to_string(),
1220                        exports: vec![],
1221                        modified_files: vec![],
1222                    },
1223                },
1224                "agent.artifact",
1225            ),
1226            (
1227                BrokerMessage::Blocked {
1228                    agent_id: "feat-a".to_string(),
1229                    payload: BlockedPayload {
1230                        needs: "auth token".to_string(),
1231                        from: "feat-b".to_string(),
1232                    },
1233                },
1234                "agent.blocked",
1235            ),
1236            (
1237                BrokerMessage::Verified {
1238                    agent_id: "feat-a".to_string(),
1239                    payload: VerifiedPayload {
1240                        verified_by: "supervisor".to_string(),
1241                        message: None,
1242                    },
1243                },
1244                "agent.verified",
1245            ),
1246            (
1247                BrokerMessage::Feedback {
1248                    agent_id: "feat-a".to_string(),
1249                    payload: FeedbackPayload {
1250                        from: "supervisor".to_string(),
1251                        errors: vec![],
1252                    },
1253                },
1254                "agent.feedback",
1255            ),
1256            (
1257                BrokerMessage::Question {
1258                    agent_id: "feat-a".to_string(),
1259                    payload: QuestionPayload {
1260                        question: "rs256 or hs256?".to_string(),
1261                    },
1262                },
1263                "agent.question",
1264            ),
1265            (
1266                BrokerMessage::Intent {
1267                    agent_id: "feat-a".to_string(),
1268                    payload: IntentPayload {
1269                        files: vec!["src/a.rs".to_string()],
1270                        summary: "wire AuthClient".to_string(),
1271                        valid_for_seconds: 900,
1272                    },
1273                },
1274                "agent.intent",
1275            ),
1276        ];
1277
1278        // Sanity: assert we constructed seven distinct variants, matching
1279        // the spec'd count.
1280        assert_eq!(
1281            variants.len(),
1282            7,
1283            "expected exactly seven BrokerMessage variants"
1284        );
1285
1286        for (msg, expected_tag) in &variants {
1287            let value = serde_json::to_value(msg).expect("serialise BrokerMessage");
1288            let obj = value.as_object().unwrap_or_else(|| {
1289                panic!("BrokerMessage must serialise as JSON object; got {value:?}")
1290            });
1291            let tag = obj
1292                .get("type")
1293                .and_then(|v| v.as_str())
1294                .unwrap_or_else(|| panic!("missing 'type' on {expected_tag} envelope"));
1295            assert_eq!(
1296                tag, *expected_tag,
1297                "wire discriminator drift: expected {expected_tag}, got {tag}",
1298            );
1299        }
1300    }
1301
1302    #[test]
1303    fn question_payload_omits_from_field() {
1304        let payload = QuestionPayload {
1305            question: "what?".to_string(),
1306        };
1307        let value = serde_json::to_value(&payload).expect("serialise QuestionPayload");
1308        let obj = value
1309            .as_object()
1310            .expect("QuestionPayload must serialise as JSON object");
1311        assert!(
1312            !obj.contains_key("from"),
1313            "QuestionPayload must not have a 'from' field; got keys {:?}",
1314            obj.keys().collect::<Vec<_>>(),
1315        );
1316        // Sanity: the only documented field is `question`.
1317        assert!(
1318            obj.contains_key("question"),
1319            "QuestionPayload must serialise the 'question' field; got keys {:?}",
1320            obj.keys().collect::<Vec<_>>(),
1321        );
1322    }
1323}