1use std::fmt;
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, thiserror::Error)]
13pub enum MessageError {
14 #[error("agent_id must not be empty")]
16 EmptyAgentId,
17
18 #[error("status field must not be empty")]
20 EmptyStatusField,
21
22 #[error("needs field must not be empty")]
24 EmptyNeedsField,
25
26 #[error("from field must not be empty")]
28 EmptyFromField,
29
30 #[error("verified_by field must not be empty")]
32 EmptyVerifiedBy,
33
34 #[error("errors list must not be empty")]
36 EmptyErrors,
37
38 #[error("question field must not be empty")]
40 EmptyQuestionField,
41
42 #[error("intent files list must not be empty")]
44 EmptyIntentFiles,
45
46 #[error("intent files entry must not be empty or whitespace-only")]
48 EmptyIntentFileEntry,
49
50 #[error("intent summary field must not be empty")]
52 EmptyIntentSummary,
53
54 #[error("intent valid_for_seconds must be > 0")]
56 ZeroValidForSeconds,
57
58 #[error("merged_branch field must not be empty")]
60 EmptyMergedBranch,
61
62 #[error("new_main_sha field must not be empty")]
64 EmptyNewMainSha,
65
66 #[error("base field must not be empty")]
68 EmptyBase,
69 #[error("learning category field must not be empty")]
71 EmptyCategory,
72
73 #[error("learning title field must not be empty")]
75 EmptyTitle,
76
77 #[error("learning timestamp field must not be empty")]
79 EmptyTimestamp,
80
81 #[error("invalid message JSON: {0}")]
83 Deserialize(#[from] serde_json::Error),
84}
85
86#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
96pub struct StatusPayload {
97 pub status: String,
99 pub modified_files: Vec<String>,
101 pub message: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub cli: Option<String>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub phase: Option<String>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub detail: Option<serde_json::Value>,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct ArtifactPayload {
131 pub status: String,
133 pub exports: Vec<String>,
135 pub modified_files: Vec<String>,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141pub struct BlockedPayload {
142 pub needs: String,
144 pub from: String,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct VerifiedPayload {
151 pub verified_by: String,
153 pub message: Option<String>,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct QuestionPayload {
164 pub question: String,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(tag = "kind", rename_all = "lowercase")]
179pub enum Region {
180 Function {
182 name: String,
184 },
185 Class {
187 name: String,
189 },
190 Block {
193 anchor: String,
195 },
196 Range {
198 start_line: u32,
200 end_line: u32,
202 },
203}
204
205impl fmt::Display for Region {
206 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232#[serde(untagged)]
233pub enum FileIntent {
234 Path(String),
236 Detailed {
238 path: String,
240 #[serde(default, skip_serializing_if = "Vec::is_empty")]
243 regions: Vec<Region>,
244 },
245}
246
247impl FileIntent {
248 #[must_use]
250 pub fn path(&self) -> &str {
251 match self {
252 Self::Path(p) | Self::Detailed { path: p, .. } => p,
253 }
254 }
255
256 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286pub struct IntentPayload {
287 pub files: Vec<FileIntent>,
290 pub summary: String,
292 pub valid_for_seconds: u64,
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298pub struct FeedbackPayload {
299 pub from: String,
301 pub errors: Vec<String>,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
318pub struct AdvancedMainPayload {
319 pub from: String,
321 pub merged_branch: String,
323 pub new_main_sha: String,
326 pub base: String,
330 pub merged_at: DateTime<Utc>,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub summary: Option<String>,
335}
336
337impl AdvancedMainPayload {
338 #[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#[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 format!("{:016x}", hasher.finish())
388}
389
390#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
407pub struct LearningPayload {
408 pub id: String,
412 pub agent_id: String,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub branch_id: Option<String>,
419 pub category: String,
423 pub title: String,
425 pub body: serde_json::Value,
427 pub timestamp: String,
429}
430
431#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
443#[serde(tag = "type")]
444pub enum BrokerMessage {
445 #[serde(rename = "agent.status")]
447 Status {
448 agent_id: String,
450 payload: StatusPayload,
452 },
453 #[serde(rename = "agent.artifact")]
455 Artifact {
456 agent_id: String,
458 payload: ArtifactPayload,
460 },
461 #[serde(rename = "agent.blocked")]
463 Blocked {
464 agent_id: String,
466 payload: BlockedPayload,
468 },
469 #[serde(rename = "agent.verified")]
471 Verified {
472 agent_id: String,
474 payload: VerifiedPayload,
476 },
477 #[serde(rename = "agent.feedback")]
479 Feedback {
480 agent_id: String,
482 payload: FeedbackPayload,
484 },
485 #[serde(rename = "agent.question")]
487 Question {
488 agent_id: String,
490 payload: QuestionPayload,
492 },
493 #[serde(rename = "agent.intent")]
498 Intent {
499 agent_id: String,
501 payload: IntentPayload,
503 },
504 #[serde(rename = "agent.advanced-main")]
514 AdvancedMain {
515 #[serde(flatten)]
518 payload: AdvancedMainPayload,
519 },
520 #[serde(rename = "agent.learning")]
529 Learning {
530 payload: LearningPayload,
532 },
533 #[serde(rename = "supervisor.verify-now")]
545 VerifyNow {
546 branch_id: String,
549 },
550}
551
552impl BrokerMessage {
553 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 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 Self::AdvancedMain { payload } => &payload.from,
576 Self::Learning { payload } => &payload.agent_id,
578 Self::VerifyNow { branch_id } => branch_id,
581 }
582 }
583
584 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 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 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 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 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
810pub fn slugify_branch(name: &str) -> String {
828 let lowered = name.to_ascii_lowercase();
830
831 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 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 let trimmed = collapsed.trim_matches('-');
860
861 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 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 #[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 #[test]
1162 fn status_payload_v050_shape_round_trips_byte_equivalent() {
1163 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 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 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 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 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 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 #[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 #[test]
1656 fn file_intent_string_entry_round_trips_to_bare_string() {
1657 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 assert_eq!(serde_json::to_string(&parsed).unwrap(), json);
1681 }
1682
1683 #[test]
1684 fn file_intent_object_entry_without_regions_omits_field() {
1685 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 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 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(®ion).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 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 #[test]
1828 #[allow(clippy::too_many_lines)] 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 assert!(
2410 obj.contains_key("question"),
2411 "QuestionPayload must serialise the 'question' field; got keys {:?}",
2412 obj.keys().collect::<Vec<_>>(),
2413 );
2414 }
2415}