1use std::fmt;
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, thiserror::Error)]
12pub enum MessageError {
13 #[error("agent_id must not be empty")]
15 EmptyAgentId,
16
17 #[error("status field must not be empty")]
19 EmptyStatusField,
20
21 #[error("needs field must not be empty")]
23 EmptyNeedsField,
24
25 #[error("from field must not be empty")]
27 EmptyFromField,
28
29 #[error("verified_by field must not be empty")]
31 EmptyVerifiedBy,
32
33 #[error("errors list must not be empty")]
35 EmptyErrors,
36
37 #[error("question field must not be empty")]
39 EmptyQuestionField,
40
41 #[error("intent files list must not be empty")]
43 EmptyIntentFiles,
44
45 #[error("intent files entry must not be empty or whitespace-only")]
47 EmptyIntentFileEntry,
48
49 #[error("intent summary field must not be empty")]
51 EmptyIntentSummary,
52
53 #[error("intent valid_for_seconds must be > 0")]
55 ZeroValidForSeconds,
56
57 #[error("invalid message JSON: {0}")]
59 Deserialize(#[from] serde_json::Error),
60}
61
62#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68pub struct StatusPayload {
69 pub status: String,
71 pub modified_files: Vec<String>,
73 pub message: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub cli: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub phase: Option<String>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct ArtifactPayload {
92 pub status: String,
94 pub exports: Vec<String>,
96 pub modified_files: Vec<String>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct BlockedPayload {
103 pub needs: String,
105 pub from: String,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct VerifiedPayload {
112 pub verified_by: String,
114 pub message: Option<String>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124pub struct QuestionPayload {
125 pub question: String,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub struct IntentPayload {
138 pub files: Vec<String>,
140 pub summary: String,
142 pub valid_for_seconds: u64,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148pub struct FeedbackPayload {
149 pub from: String,
151 pub errors: Vec<String>,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(tag = "type")]
163pub enum BrokerMessage {
164 #[serde(rename = "agent.status")]
166 Status {
167 agent_id: String,
169 payload: StatusPayload,
171 },
172 #[serde(rename = "agent.artifact")]
174 Artifact {
175 agent_id: String,
177 payload: ArtifactPayload,
179 },
180 #[serde(rename = "agent.blocked")]
182 Blocked {
183 agent_id: String,
185 payload: BlockedPayload,
187 },
188 #[serde(rename = "agent.verified")]
190 Verified {
191 agent_id: String,
193 payload: VerifiedPayload,
195 },
196 #[serde(rename = "agent.feedback")]
198 Feedback {
199 agent_id: String,
201 payload: FeedbackPayload,
203 },
204 #[serde(rename = "agent.question")]
206 Question {
207 agent_id: String,
209 payload: QuestionPayload,
211 },
212 #[serde(rename = "agent.intent")]
217 Intent {
218 agent_id: String,
220 payload: IntentPayload,
222 },
223}
224
225impl BrokerMessage {
226 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 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 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 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
416pub fn slugify_branch(name: &str) -> String {
434 let lowered = name.to_ascii_lowercase();
436
437 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 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 let trimmed = collapsed.trim_matches('-');
466
467 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 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 #[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 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 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 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 #[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 #[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 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 assert!(
1318 obj.contains_key("question"),
1319 "QuestionPayload must serialise the 'question' field; got keys {:?}",
1320 obj.keys().collect::<Vec<_>>(),
1321 );
1322 }
1323}