1use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use uuid::Uuid;
10
11use crate::comms::{
12 PeerId, PeerLifecycleKind, PeerName, PeerRoute, SUPERVISOR_BRIDGE_INTENT, TrustedPeerDescriptor,
13};
14use crate::types::{ContentBlock, HandlingMode, RenderMetadata};
15
16#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub struct InteractionId(#[cfg_attr(feature = "schema", schemars(with = "String"))] pub Uuid);
20
21impl std::fmt::Display for InteractionId {
22 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23 self.0.fmt(f)
24 }
25}
26
27#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum ResponseStatus {
34 Accepted,
35 Completed,
36 Failed,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
46#[non_exhaustive]
47pub enum TerminalityClass {
48 Progress,
49 Terminal { disposition: TerminalDisposition },
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
53#[non_exhaustive]
54pub enum TerminalDisposition {
55 Completed,
56 Failed,
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(tag = "type", rename_all = "snake_case")]
65pub enum InteractionContent {
66 Message {
68 body: String,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 blocks: Option<Vec<ContentBlock>>,
72 },
73 Request {
75 intent: String,
76 params: Value,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 blocks: Option<Vec<ContentBlock>>,
79 },
80 Response {
82 in_reply_to: InteractionId,
83 status: ResponseStatus,
84 result: Value,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 blocks: Option<Vec<ContentBlock>>,
87 },
88}
89
90#[derive(Debug, Clone)]
92pub struct InboxInteraction {
93 pub id: InteractionId,
95 pub from_route: Option<PeerId>,
98 pub from: String,
100 pub content: InteractionContent,
102 pub rendered_text: String,
104 pub handling_mode: HandlingMode,
106 pub render_metadata: Option<RenderMetadata>,
108}
109
110pub fn format_external_event_projection(source_name: &str, body: Option<&str>) -> String {
116 let label = format!("External event via {source_name}");
117 let body = body.map(str::trim).filter(|body| !body.is_empty());
118
119 match body {
120 Some(body) => format!("{label}: {body}"),
121 None => label,
122 }
123}
124
125pub fn format_peer_message_projection(from_peer: &str, body: &str) -> String {
127 format!("Peer message from {from_peer}:\n{body}")
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct SendResponseCallProjection {
137 pub peer_id: PeerId,
138 pub display_name: Option<String>,
139 pub in_reply_to: String,
140}
141
142impl SendResponseCallProjection {
143 pub const TOOL_NAME: &'static str = "send_response";
144 pub const PEER_ID_FIELD: &'static str = "peer_id";
145 pub const DISPLAY_NAME_FIELD: &'static str = "display_name";
146 pub const IN_REPLY_TO_FIELD: &'static str = "in_reply_to";
147 pub const STATUS_FIELD: &'static str = "status";
148 pub const RESULT_FIELD: &'static str = "result";
149
150 pub fn new(
151 peer_id: PeerId,
152 display_name: Option<&str>,
153 in_reply_to: impl Into<String>,
154 ) -> Self {
155 Self {
156 peer_id,
157 display_name: display_name
158 .map(str::trim)
159 .filter(|name| !name.is_empty())
160 .map(ToOwned::to_owned),
161 in_reply_to: in_reply_to.into(),
162 }
163 }
164
165 pub fn completed_example_args(&self) -> Value {
171 let mut args = serde_json::Map::new();
172 args.insert(
173 Self::PEER_ID_FIELD.to_string(),
174 Value::String(self.peer_id.to_string()),
175 );
176 if let Some(display_name) = &self.display_name {
177 args.insert(
178 Self::DISPLAY_NAME_FIELD.to_string(),
179 Value::String(display_name.clone()),
180 );
181 }
182 args.insert(
183 Self::IN_REPLY_TO_FIELD.to_string(),
184 Value::String(self.in_reply_to.clone()),
185 );
186 args.insert(
187 Self::STATUS_FIELD.to_string(),
188 Value::String("completed".to_string()),
189 );
190 Value::Object(args)
191 }
192
193 pub fn instruction_text(&self) -> String {
194 let args = serde_json::to_string(&self.completed_example_args())
195 .unwrap_or_else(|_| "{}".to_string());
196 format!(
197 "Reply with {} with arguments {args}. Use status=\"failed\" instead of \"completed\" when the request cannot be fulfilled, and include result only when the request contract provides a typed result payload.",
198 Self::TOOL_NAME
199 )
200 }
201}
202
203pub fn format_peer_request_projection(
205 from_peer_id: PeerId,
206 display_name: Option<&str>,
207 request_id: impl std::fmt::Display,
208 intent: &str,
209 params: &Value,
210) -> String {
211 let params_str = if params.is_null() || matches!(params, Value::Object(map) if map.is_empty()) {
212 String::new()
213 } else {
214 format!(
215 "\nParams: {}",
216 serde_json::to_string_pretty(params).unwrap_or_default()
217 )
218 };
219 let request_id = request_id.to_string();
220 let display_suffix = display_name
221 .map(str::trim)
222 .filter(|name| !name.is_empty())
223 .map(|name| format!(" (display_name: {name})"))
224 .unwrap_or_default();
225 let response_call =
226 SendResponseCallProjection::new(from_peer_id, display_name, request_id.clone());
227
228 format!(
229 "Peer request from peer_id {from_peer_id}{display_suffix} (id: {request_id})\n\
230 Intent: {intent}{params_str}\n\
231 Request ID: {request_id}\n\
232 \n\
233 This is a correlated peer request. {} \
234 Do not answer this request with send_message.",
235 response_call.instruction_text()
236 )
237}
238
239pub fn format_peer_response_projection(
241 from_peer: &str,
242 in_reply_to: impl std::fmt::Display,
243 status: ResponseStatus,
244 result: &Value,
245) -> String {
246 let status_str = match status {
247 ResponseStatus::Accepted => "accepted",
248 ResponseStatus::Completed => "completed",
249 ResponseStatus::Failed => "failed",
250 };
251 let result_str = if result.is_null() || matches!(result, Value::Object(map) if map.is_empty()) {
252 String::new()
253 } else {
254 format!(
255 "\nResult: {}",
256 serde_json::to_string_pretty(result).unwrap_or_default()
257 )
258 };
259
260 format!(
261 "Peer response from {from_peer} (to request: {in_reply_to})\n\
262 Status: {status_str}{result_str}"
263 )
264}
265
266pub fn format_peer_ack_projection(from_peer: &str, in_reply_to: impl std::fmt::Display) -> String {
268 format!("Peer ack from {from_peer} (to request: {in_reply_to})")
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub enum PeerInputClass {
277 ActionableMessage,
279 ActionableRequest,
281 ResponseProgress,
283 ResponseTerminal,
285 PeerLifecycleAdded,
287 PeerLifecycleRetired,
289 PeerLifecycleUnwired,
291 PeerLifecycleKickoffFailed,
293 PeerLifecycleKickoffCancelled,
295 SilentRequest,
297 Ack,
299 PlainEvent,
301}
302
303const fn peer_input_class_actionable_grouping(class: PeerInputClass) -> bool {
313 matches!(
314 class,
315 PeerInputClass::ActionableMessage
316 | PeerInputClass::ActionableRequest
317 | PeerInputClass::ResponseProgress
318 | PeerInputClass::ResponseTerminal
319 | PeerInputClass::PlainEvent
320 | PeerInputClass::PeerLifecycleKickoffFailed
321 | PeerInputClass::PeerLifecycleKickoffCancelled
322 )
323}
324
325#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
327pub enum PeerIngressAuthExemption {
328 SupervisorBridge,
330}
331
332impl PeerIngressAuthExemption {
333 pub const fn intent(self) -> &'static str {
334 match self {
335 Self::SupervisorBridge => SUPERVISOR_BRIDGE_INTENT,
336 }
337 }
338
339 pub fn matches_intent(self, intent: &str) -> bool {
340 self.intent() == intent
341 }
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
346pub enum PeerIngressAuthDecision {
347 Required,
349 Exempt(PeerIngressAuthExemption),
351}
352
353impl PeerIngressAuthDecision {
354 pub const fn is_exempt(self) -> bool {
355 matches!(self, Self::Exempt(_))
356 }
357}
358
359#[derive(Debug, Clone, PartialEq, Eq)]
365pub enum PeerIngressConvention {
366 Message,
367 Request {
368 request_id: String,
369 intent: String,
370 },
371 Response {
372 in_reply_to: InteractionId,
373 status: ResponseStatus,
374 },
375 Ack {
376 in_reply_to: InteractionId,
377 },
378 Lifecycle {
379 kind: PeerLifecycleKind,
380 peer: String,
381 },
382 PlainEvent {
383 source_name: String,
384 },
385}
386
387#[derive(Debug, Clone, PartialEq, Eq)]
393pub struct PeerIngressFact {
394 pub interaction_id: InteractionId,
396 pub class: PeerInputClass,
398 pub kind: PeerIngressKind,
400 pub canonical_peer_id: Option<PeerId>,
402 pub display_name: Option<PeerName>,
404 pub signing_pubkey: Option<[u8; 32]>,
406 pub route: Option<PeerRoute>,
408 pub auth: Option<PeerIngressAuthDecision>,
410 pub convention: PeerIngressConvention,
412}
413
414#[derive(Debug, Clone, PartialEq, Eq)]
416pub struct PeerIngressIdentity {
417 pub canonical_peer_id: PeerId,
418 pub display_label: String,
419 pub signing_pubkey: Option<[u8; 32]>,
420 pub convention: PeerIngressConvention,
421}
422
423impl PeerIngressIdentity {
424 pub fn new(
425 canonical_peer_id: PeerId,
426 display_label: impl Into<String>,
427 convention: PeerIngressConvention,
428 ) -> Self {
429 Self {
430 canonical_peer_id,
431 display_label: display_label.into(),
432 signing_pubkey: None,
433 convention,
434 }
435 }
436
437 pub fn with_signing_pubkey(mut self, signing_pubkey: [u8; 32]) -> Self {
438 self.signing_pubkey = Some(signing_pubkey);
439 self
440 }
441}
442
443impl PeerIngressFact {
444 pub fn peer(
445 interaction_id: InteractionId,
446 class: PeerInputClass,
447 kind: PeerIngressKind,
448 auth: Option<PeerIngressAuthDecision>,
449 identity: PeerIngressIdentity,
450 ) -> Self {
451 let PeerIngressIdentity {
452 canonical_peer_id,
453 display_label,
454 signing_pubkey,
455 convention,
456 } = identity;
457 let display_name = PeerName::new(display_label).ok();
458 let route = Some(match &display_name {
459 Some(name) => PeerRoute::with_display_name(canonical_peer_id, name.clone()),
460 None => PeerRoute::new(canonical_peer_id),
461 });
462 Self {
463 interaction_id,
464 class,
465 kind,
466 canonical_peer_id: Some(canonical_peer_id),
467 display_name,
468 signing_pubkey,
469 route,
470 auth,
471 convention,
472 }
473 }
474
475 pub fn plain_event(
476 interaction_id: InteractionId,
477 source_name: impl Into<String>,
478 class: PeerInputClass,
479 kind: PeerIngressKind,
480 ) -> Self {
481 let source_name = source_name.into();
482 Self {
483 interaction_id,
484 class,
485 kind,
486 canonical_peer_id: None,
487 display_name: None,
488 signing_pubkey: None,
489 route: None,
490 auth: None,
491 convention: PeerIngressConvention::PlainEvent { source_name },
492 }
493 }
494
495 pub fn canonical_peer_id_string(&self) -> Option<String> {
496 self.canonical_peer_id.map(|peer_id| peer_id.as_str())
497 }
498
499 pub fn display_label(&self) -> Option<String> {
500 self.display_name.as_ref().map(PeerName::as_string)
501 }
502
503 pub fn diagnostic_label(&self) -> String {
504 self.display_label()
505 .or_else(|| self.canonical_peer_id_string())
506 .unwrap_or_else(|| "<unknown-peer-ingress>".to_string())
507 }
508
509 pub fn plain_event_source_name(&self) -> Option<&str> {
510 match &self.convention {
511 PeerIngressConvention::PlainEvent { source_name } => Some(source_name.as_str()),
512 _ => None,
513 }
514 }
515}
516
517#[derive(Debug, Clone, PartialEq, Eq)]
519pub struct PeerIngressClassification {
520 pub class: PeerInputClass,
521 pub actionable: bool,
527 pub kind: PeerIngressKind,
528 pub auth: PeerIngressAuthDecision,
529 pub lifecycle_kind: Option<PeerLifecycleKind>,
530 pub response_terminality: Option<TerminalityClass>,
531}
532
533impl PeerIngressClassification {
534 pub const fn required(class: PeerInputClass, kind: PeerIngressKind) -> Self {
535 Self {
536 class,
537 actionable: peer_input_class_actionable_grouping(class),
538 kind,
539 auth: PeerIngressAuthDecision::Required,
540 lifecycle_kind: None,
541 response_terminality: None,
542 }
543 }
544}
545
546#[derive(Debug, Clone, PartialEq)]
552pub struct PeerIngressEnvelopeFacts {
553 pub item_id: String,
554 pub from_peer: String,
555 pub from_peer_id: PeerId,
556 pub kind: PeerIngressEnvelopeKind,
557}
558
559#[derive(Debug, Clone, PartialEq)]
560pub enum PeerIngressEnvelopeKind {
561 Message {
562 body: String,
563 },
564 Request {
565 intent: String,
566 params: Value,
567 },
568 Lifecycle {
569 kind: PeerLifecycleKind,
570 params: Value,
571 },
572 Response {
573 in_reply_to: String,
574 status: ResponseStatus,
575 result: Value,
576 },
577 Ack {
578 in_reply_to: String,
579 },
580}
581
582#[derive(Debug, Clone, PartialEq, Eq)]
584pub struct PeerIngressPlainEventFacts {
585 pub source_name: String,
586 pub body: String,
587}
588
589#[derive(Debug, Clone, PartialEq, Eq)]
591pub struct PeerIngressAdmission {
592 pub classification: PeerIngressClassification,
593 pub from_peer_id: Option<PeerId>,
599 pub lifecycle_peer: Option<String>,
600 pub request_id: Option<String>,
601 pub rendered_text: String,
602}
603
604#[derive(Debug, Clone, Copy, PartialEq, Eq)]
610pub struct PeerIngressReceiveFacts {
611 pub kind: PeerIngressKind,
612 pub current_phase: PeerIngressAuthorityPhase,
613 pub auth_required: bool,
614 pub auth_exempt: bool,
615 pub trusted: bool,
616 pub queued_work_present: bool,
617 pub queue_closed: bool,
618 pub queue_capacity_available: bool,
619}
620
621#[derive(Debug, Clone, Copy, PartialEq, Eq)]
623pub struct PeerIngressReceiveAuthority {
624 pub outcome: PeerIngressReceiveOutcome,
625 pub admission_diagnostic: Option<PeerIngressAdmissionDiagnostic>,
626 pub authority_phase: PeerIngressAuthorityPhase,
627}
628
629#[derive(Debug, Clone, Copy, PartialEq, Eq)]
631pub enum PeerIngressReceiveOutcome {
632 Admitted,
633 DroppedUntrustedSender,
634 DroppedSessionClosed,
635 DroppedInboxFull,
636}
637
638#[derive(Debug, Clone, Copy, PartialEq, Eq)]
643pub struct PeerIngressDequeueFacts {
644 pub kind: PeerIngressKind,
645 pub auth: PeerIngressAuthDecision,
646 pub queued_work_remaining: bool,
647}
648
649#[derive(Debug, Clone, Copy, PartialEq, Eq)]
651pub struct PeerIngressDequeueAuthority {
652 pub authority_phase: PeerIngressAuthorityPhase,
653}
654
655pub fn render_peer_ingress_admitted_text(
661 facts: &PeerIngressEnvelopeFacts,
662 classification: &PeerIngressClassification,
663) -> String {
664 match &facts.kind {
665 PeerIngressEnvelopeKind::Message { body } => {
666 format_peer_message_projection(&facts.from_peer, body)
667 }
668 PeerIngressEnvelopeKind::Request { intent, params } => {
669 if classification.lifecycle_kind.is_some() {
670 String::new()
671 } else {
672 format_peer_request_projection(
673 facts.from_peer_id,
674 Some(&facts.from_peer),
675 facts.item_id.as_str(),
676 intent,
677 params,
678 )
679 }
680 }
681 PeerIngressEnvelopeKind::Lifecycle { .. } => String::new(),
682 PeerIngressEnvelopeKind::Response {
683 in_reply_to,
684 status,
685 result,
686 } => format_peer_response_projection(&facts.from_peer, in_reply_to, *status, result),
687 PeerIngressEnvelopeKind::Ack { in_reply_to } => {
688 format_peer_ack_projection(&facts.from_peer, in_reply_to)
689 }
690 }
691}
692
693#[derive(Debug, Clone)]
699pub struct PeerInputCandidate {
700 pub interaction: InboxInteraction,
702 pub ingress: PeerIngressFact,
705 pub lifecycle_peer: Option<String>,
707 pub response_terminality: Option<TerminalityClass>,
709}
710
711impl PeerInputCandidate {
712 pub fn new(
713 interaction: InboxInteraction,
714 ingress: PeerIngressFact,
715 lifecycle_peer: Option<String>,
716 ) -> Self {
717 Self {
718 interaction,
719 ingress,
720 lifecycle_peer,
721 response_terminality: None,
722 }
723 }
724
725 pub fn class(&self) -> PeerInputClass {
726 self.ingress.class
727 }
728
729 pub fn kind(&self) -> PeerIngressKind {
730 self.ingress.kind
731 }
732
733 pub fn auth(&self) -> Option<PeerIngressAuthDecision> {
734 self.ingress.auth
735 }
736
737 pub fn from_peer_id(&self) -> Option<PeerId> {
744 self.ingress.canonical_peer_id
745 }
746}
747
748pub type ClassifiedInboxInteraction = PeerInputCandidate;
750
751#[derive(Debug, Clone, Copy, PartialEq, Eq)]
756pub enum PeerIngressKind {
757 Message,
758 Request,
759 Response,
760 Ack,
761 PlainEvent,
762}
763
764#[derive(Debug, Clone, PartialEq, Eq)]
771pub struct PeerIngressDiagnosticDisplay(String);
772
773impl PeerIngressDiagnosticDisplay {
774 pub fn new(value: impl Into<String>) -> Self {
775 Self(value.into())
776 }
777
778 pub fn as_str(&self) -> &str {
779 &self.0
780 }
781}
782
783impl std::fmt::Display for PeerIngressDiagnosticDisplay {
784 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
785 self.0.fmt(f)
786 }
787}
788
789#[derive(Debug, Clone, Copy, PartialEq, Eq)]
795pub enum PeerIngressAdmissionDiagnostic {
796 TrustedAtAdmission,
797 UntrustedAtAdmission,
798}
799
800impl PeerIngressAdmissionDiagnostic {
801 pub const fn from_trusted(trusted: bool) -> Self {
802 if trusted {
803 Self::TrustedAtAdmission
804 } else {
805 Self::UntrustedAtAdmission
806 }
807 }
808
809 pub const fn trusted_at_admission(self) -> bool {
810 matches!(self, Self::TrustedAtAdmission)
811 }
812}
813
814#[derive(Debug, Clone, PartialEq, Eq)]
821pub struct PeerIngressEntrySnapshot {
822 pub raw_item_id: InteractionId,
824 pub interaction_id: Option<InteractionId>,
826 pub class: PeerInputClass,
828 pub actionable: bool,
832 pub kind: PeerIngressKind,
834 pub from_peer_display: Option<PeerIngressDiagnosticDisplay>,
836 pub canonical_peer_id: Option<PeerId>,
838 pub display_name: Option<PeerName>,
840 pub signing_pubkey: Option<[u8; 32]>,
842 pub route: Option<PeerRoute>,
844 pub lifecycle_peer_display: Option<PeerIngressDiagnosticDisplay>,
846 pub request_correlation_id: Option<InteractionId>,
848 pub auth: Option<PeerIngressAuthDecision>,
851 pub admission_diagnostic: Option<PeerIngressAdmissionDiagnostic>,
854 pub response_terminality: Option<TerminalityClass>,
857}
858
859#[derive(Debug, Clone, PartialEq, Eq, Default)]
865pub struct PeerIngressQueueSnapshot {
866 pub total_count: usize,
867 pub actionable_count: usize,
868 pub response_count: usize,
869 pub lifecycle_count: usize,
870 pub silent_request_count: usize,
871 pub ack_count: usize,
872 pub plain_event_count: usize,
873 pub queued_entries: Vec<PeerIngressEntrySnapshot>,
874}
875
876#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
881pub enum PeerIngressAuthorityPhase {
882 #[default]
883 Absent,
884 Received,
885 Dropped,
886 Delivered,
887}
888
889#[derive(Debug, Clone, PartialEq, Eq)]
894pub struct PeerIngressRuntimeSnapshot {
895 pub self_peer_id: crate::comms::PeerId,
897 pub auth_required: bool,
899 pub authority_phase: PeerIngressAuthorityPhase,
901 pub trusted_peers: Vec<TrustedPeerDescriptor>,
903 pub submission_queue_len: usize,
905 pub queue: PeerIngressQueueSnapshot,
907}
908
909#[cfg(test)]
910#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
911mod tests {
912 use super::*;
913
914 #[test]
915 fn interaction_id_json_roundtrip() {
916 let id = InteractionId(Uuid::new_v4());
917 let json = serde_json::to_string(&id).unwrap();
918 let parsed: InteractionId = serde_json::from_str(&json).unwrap();
919 assert_eq!(id, parsed);
920 }
921
922 #[test]
923 fn interaction_content_message_json_roundtrip() {
924 let content = InteractionContent::Message {
925 body: "hello".to_string(),
926 blocks: None,
927 };
928 let json = serde_json::to_value(&content).unwrap();
929 assert_eq!(json["type"], "message");
930 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
931 assert_eq!(content, parsed);
932 }
933
934 #[test]
935 fn interaction_content_request_json_roundtrip() {
936 let content = InteractionContent::Request {
937 intent: "review".to_string(),
938 params: serde_json::json!({"pr": 42}),
939 blocks: None,
940 };
941 let json = serde_json::to_value(&content).unwrap();
942 assert_eq!(json["type"], "request");
943 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
944 assert_eq!(content, parsed);
945 }
946
947 #[test]
948 fn interaction_content_response_json_roundtrip() {
949 let id = InteractionId(Uuid::new_v4());
950 let content = InteractionContent::Response {
951 in_reply_to: id,
952 status: ResponseStatus::Completed,
953 result: serde_json::json!({"ok": true}),
954 blocks: None,
955 };
956 let json = serde_json::to_value(&content).unwrap();
957 assert_eq!(json["type"], "response");
958 assert_eq!(json["status"], "completed");
959 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
960 assert_eq!(content, parsed);
961 }
962
963 #[test]
964 fn response_status_json_roundtrip_all_variants() {
965 for (variant, expected_str) in [
966 (ResponseStatus::Accepted, "accepted"),
967 (ResponseStatus::Completed, "completed"),
968 (ResponseStatus::Failed, "failed"),
969 ] {
970 let json = serde_json::to_value(variant).unwrap();
971 assert_eq!(json, expected_str);
972 let parsed: ResponseStatus = serde_json::from_value(json).unwrap();
973 assert_eq!(variant, parsed);
974 }
975 }
976
977 #[test]
978 fn interaction_message_with_blocks_roundtrip() {
979 let content = InteractionContent::Message {
980 body: "hello".to_string(),
981 blocks: Some(vec![
982 ContentBlock::Text {
983 text: "hello".to_string(),
984 },
985 ContentBlock::Image {
986 media_type: "image/png".to_string(),
987 data: "iVBORw0KGgo=".into(),
988 },
989 ]),
990 };
991 let json = serde_json::to_value(&content).unwrap();
992 assert_eq!(json["type"], "message");
993 assert!(json["blocks"].is_array());
994 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
995 assert_eq!(content, parsed);
996 }
997
998 #[test]
999 fn inbox_interaction_preserves_runtime_hints() {
1000 let interaction = InboxInteraction {
1001 id: InteractionId(Uuid::new_v4()),
1002 from_route: None,
1003 from: "event:webhook".into(),
1004 content: InteractionContent::Message {
1005 body: "hello".into(),
1006 blocks: None,
1007 },
1008 rendered_text: "External event via webhook: hello".into(),
1009 handling_mode: HandlingMode::Steer,
1010 render_metadata: Some(RenderMetadata {
1011 class: crate::types::RenderClass::SystemNotice,
1012 salience: crate::types::RenderSalience::Urgent,
1013 }),
1014 };
1015
1016 assert_eq!(interaction.handling_mode, HandlingMode::Steer);
1017 assert!(interaction.render_metadata.is_some());
1018 }
1019
1020 #[test]
1021 fn interaction_message_without_blocks_compat() {
1022 let old_json = r#"{"type":"message","body":"hello"}"#;
1024 let parsed: InteractionContent = serde_json::from_str(old_json).unwrap();
1025 match parsed {
1026 InteractionContent::Message { body, blocks } => {
1027 assert_eq!(body, "hello");
1028 assert_eq!(blocks, None);
1029 }
1030 other => panic!("Expected Message, got {other:?}"),
1031 }
1032
1033 let content = InteractionContent::Message {
1035 body: "test".to_string(),
1036 blocks: None,
1037 };
1038 let json = serde_json::to_string(&content).unwrap();
1039 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1040 assert!(
1041 value.get("blocks").is_none(),
1042 "blocks: None should not appear in JSON"
1043 );
1044 }
1045
1046 #[test]
1053 fn actionable_grouping_mirror_matches_machine_grouping_for_all_variants() {
1054 for (class, expected_actionable) in [
1057 (PeerInputClass::ActionableMessage, true),
1058 (PeerInputClass::ActionableRequest, true),
1059 (PeerInputClass::ResponseProgress, true),
1060 (PeerInputClass::ResponseTerminal, true),
1061 (PeerInputClass::PlainEvent, true),
1062 (PeerInputClass::PeerLifecycleKickoffFailed, true),
1063 (PeerInputClass::PeerLifecycleKickoffCancelled, true),
1064 (PeerInputClass::PeerLifecycleAdded, false),
1065 (PeerInputClass::PeerLifecycleRetired, false),
1066 (PeerInputClass::PeerLifecycleUnwired, false),
1067 (PeerInputClass::SilentRequest, false),
1068 (PeerInputClass::Ack, false),
1069 ] {
1070 assert_eq!(
1071 peer_input_class_actionable_grouping(class),
1072 expected_actionable,
1073 "actionable grouping verdict drifted for {class:?}"
1074 );
1075 }
1076 fn assert_variant_covered(class: PeerInputClass) {
1080 match class {
1081 PeerInputClass::ActionableMessage
1082 | PeerInputClass::ActionableRequest
1083 | PeerInputClass::ResponseProgress
1084 | PeerInputClass::ResponseTerminal
1085 | PeerInputClass::PlainEvent
1086 | PeerInputClass::PeerLifecycleKickoffFailed
1087 | PeerInputClass::PeerLifecycleKickoffCancelled
1088 | PeerInputClass::PeerLifecycleAdded
1089 | PeerInputClass::PeerLifecycleRetired
1090 | PeerInputClass::PeerLifecycleUnwired
1091 | PeerInputClass::SilentRequest
1092 | PeerInputClass::Ack => (),
1093 }
1094 }
1095 assert_variant_covered(PeerInputClass::Ack);
1096 }
1097}