1use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use uuid::Uuid;
10
11use crate::comms::{
12 PeerId, PeerLifecycleKind, PeerName, PeerRoute, SUPERVISOR_BRIDGE_INTENT, SenderContentTaint,
13 TrustedPeerDescriptor,
14};
15use crate::types::{ContentBlock, HandlingMode, RenderMetadata};
16
17#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub struct InteractionId(#[cfg_attr(feature = "schema", schemars(with = "String"))] pub Uuid);
21
22impl std::fmt::Display for InteractionId {
23 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24 self.0.fmt(f)
25 }
26}
27
28#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum ResponseStatus {
35 Accepted,
36 Completed,
37 Failed,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47#[non_exhaustive]
48pub enum TerminalityClass {
49 Progress,
50 Terminal { disposition: TerminalDisposition },
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54#[non_exhaustive]
55pub enum TerminalDisposition {
56 Completed,
57 Failed,
58}
59
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65#[serde(tag = "type", rename_all = "snake_case")]
66pub enum InteractionContent {
67 Message {
69 body: String,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 blocks: Option<Vec<ContentBlock>>,
73 },
74 Request {
76 intent: String,
77 params: Value,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 blocks: Option<Vec<ContentBlock>>,
80 },
81 Response {
83 in_reply_to: InteractionId,
84 status: ResponseStatus,
85 result: Value,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 blocks: Option<Vec<ContentBlock>>,
88 },
89}
90
91#[derive(Debug, Clone)]
93pub struct InboxInteraction {
94 pub id: InteractionId,
96 pub from_route: Option<PeerId>,
99 pub from: String,
101 pub content: InteractionContent,
103 pub rendered_text: String,
105 pub handling_mode: HandlingMode,
107 pub render_metadata: Option<RenderMetadata>,
109 pub sender_taint: Option<SenderContentTaint>,
115}
116
117pub fn format_external_event_projection(source_name: &str, body: Option<&str>) -> String {
123 let label = format!("External event via {source_name}");
124 let body = body.map(str::trim).filter(|body| !body.is_empty());
125
126 match body {
127 Some(body) => format!("{label}: {body}"),
128 None => label,
129 }
130}
131
132pub fn format_peer_message_projection(from_peer: &str, body: &str) -> String {
134 format!("Peer message from {from_peer}:\n{body}")
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct SendResponseCallProjection {
144 pub peer_id: PeerId,
145 pub display_name: Option<String>,
146 pub in_reply_to: String,
147}
148
149impl SendResponseCallProjection {
150 pub const TOOL_NAME: &'static str = "send_response";
151 pub const PEER_ID_FIELD: &'static str = "peer_id";
152 pub const DISPLAY_NAME_FIELD: &'static str = "display_name";
153 pub const IN_REPLY_TO_FIELD: &'static str = "in_reply_to";
154 pub const STATUS_FIELD: &'static str = "status";
155 pub const RESULT_FIELD: &'static str = "result";
156
157 pub fn new(
158 peer_id: PeerId,
159 display_name: Option<&str>,
160 in_reply_to: impl Into<String>,
161 ) -> Self {
162 Self {
163 peer_id,
164 display_name: display_name
165 .map(str::trim)
166 .filter(|name| !name.is_empty())
167 .map(ToOwned::to_owned),
168 in_reply_to: in_reply_to.into(),
169 }
170 }
171
172 pub fn completed_example_args(&self) -> Value {
178 let mut args = serde_json::Map::new();
179 args.insert(
180 Self::PEER_ID_FIELD.to_string(),
181 Value::String(self.peer_id.to_string()),
182 );
183 if let Some(display_name) = &self.display_name {
184 args.insert(
185 Self::DISPLAY_NAME_FIELD.to_string(),
186 Value::String(display_name.clone()),
187 );
188 }
189 args.insert(
190 Self::IN_REPLY_TO_FIELD.to_string(),
191 Value::String(self.in_reply_to.clone()),
192 );
193 args.insert(
194 Self::STATUS_FIELD.to_string(),
195 Value::String("completed".to_string()),
196 );
197 Value::Object(args)
198 }
199
200 pub fn instruction_text(&self) -> String {
201 let args = serde_json::to_string(&self.completed_example_args())
202 .unwrap_or_else(|_| "{}".to_string());
203 format!(
204 "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.",
205 Self::TOOL_NAME
206 )
207 }
208}
209
210pub fn format_peer_request_projection(
212 from_peer_id: PeerId,
213 display_name: Option<&str>,
214 request_id: impl std::fmt::Display,
215 intent: &str,
216 params: &Value,
217) -> String {
218 let params_str = if params.is_null() || matches!(params, Value::Object(map) if map.is_empty()) {
219 String::new()
220 } else {
221 format!(
222 "\nParams: {}",
223 serde_json::to_string_pretty(params).unwrap_or_default()
224 )
225 };
226 let request_id = request_id.to_string();
227 let display_suffix = display_name
228 .map(str::trim)
229 .filter(|name| !name.is_empty())
230 .map(|name| format!(" (display_name: {name})"))
231 .unwrap_or_default();
232 let response_call =
233 SendResponseCallProjection::new(from_peer_id, display_name, request_id.clone());
234
235 format!(
236 "Peer request from peer_id {from_peer_id}{display_suffix} (id: {request_id})\n\
237 Intent: {intent}{params_str}\n\
238 Request ID: {request_id}\n\
239 \n\
240 This is a correlated peer request. {} \
241 Do not answer this request with send_message.",
242 response_call.instruction_text()
243 )
244}
245
246pub fn format_peer_response_projection(
248 from_peer: &str,
249 in_reply_to: impl std::fmt::Display,
250 status: ResponseStatus,
251 result: &Value,
252) -> String {
253 let status_str = match status {
254 ResponseStatus::Accepted => "accepted",
255 ResponseStatus::Completed => "completed",
256 ResponseStatus::Failed => "failed",
257 };
258 let result_str = if result.is_null() || matches!(result, Value::Object(map) if map.is_empty()) {
259 String::new()
260 } else {
261 format!(
262 "\nResult: {}",
263 serde_json::to_string_pretty(result).unwrap_or_default()
264 )
265 };
266
267 format!(
268 "Peer response from {from_peer} (to request: {in_reply_to})\n\
269 Status: {status_str}{result_str}"
270 )
271}
272
273pub fn format_peer_ack_projection(from_peer: &str, in_reply_to: impl std::fmt::Display) -> String {
275 format!("Peer ack from {from_peer} (to request: {in_reply_to})")
276}
277
278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum PeerInputClass {
284 ActionableMessage,
286 ActionableRequest,
288 ResponseProgress,
290 ResponseTerminal,
292 PeerLifecycleAdded,
294 PeerLifecycleRetired,
296 PeerLifecycleUnwired,
298 PeerLifecycleKickoffFailed,
300 PeerLifecycleKickoffCancelled,
302 SilentRequest,
304 Ack,
306 PlainEvent,
308}
309
310const fn peer_input_class_actionable_grouping(class: PeerInputClass) -> bool {
320 matches!(
321 class,
322 PeerInputClass::ActionableMessage
323 | PeerInputClass::ActionableRequest
324 | PeerInputClass::ResponseProgress
325 | PeerInputClass::ResponseTerminal
326 | PeerInputClass::PlainEvent
327 | PeerInputClass::PeerLifecycleKickoffFailed
328 | PeerInputClass::PeerLifecycleKickoffCancelled
329 )
330}
331
332#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
334pub enum PeerIngressAuthExemption {
335 SupervisorBridge,
337}
338
339impl PeerIngressAuthExemption {
340 pub const fn intent(self) -> &'static str {
341 match self {
342 Self::SupervisorBridge => SUPERVISOR_BRIDGE_INTENT,
343 }
344 }
345
346 pub fn matches_intent(self, intent: &str) -> bool {
347 self.intent() == intent
348 }
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
353pub enum PeerIngressAuthDecision {
354 Required,
356 Exempt(PeerIngressAuthExemption),
358}
359
360impl PeerIngressAuthDecision {
361 pub const fn is_exempt(self) -> bool {
362 matches!(self, Self::Exempt(_))
363 }
364}
365
366#[derive(Debug, Clone, PartialEq, Eq)]
372pub enum PeerIngressConvention {
373 Message,
374 Request {
375 request_id: String,
376 intent: String,
377 },
378 Response {
379 in_reply_to: InteractionId,
380 status: ResponseStatus,
381 },
382 Ack {
383 in_reply_to: InteractionId,
384 },
385 Lifecycle {
386 kind: PeerLifecycleKind,
387 peer: String,
388 },
389 PlainEvent {
390 source_name: String,
391 },
392}
393
394#[derive(Debug, Clone, PartialEq, Eq)]
400pub struct PeerIngressFact {
401 pub interaction_id: InteractionId,
403 pub class: PeerInputClass,
405 pub kind: PeerIngressKind,
407 pub canonical_peer_id: Option<PeerId>,
409 pub display_name: Option<PeerName>,
411 pub signing_pubkey: Option<[u8; 32]>,
413 pub route: Option<PeerRoute>,
415 pub auth: Option<PeerIngressAuthDecision>,
417 pub convention: PeerIngressConvention,
419}
420
421#[derive(Debug, Clone, PartialEq, Eq)]
423pub struct PeerIngressIdentity {
424 pub canonical_peer_id: PeerId,
425 pub display_label: String,
426 pub signing_pubkey: Option<[u8; 32]>,
427 pub convention: PeerIngressConvention,
428}
429
430impl PeerIngressIdentity {
431 pub fn new(
432 canonical_peer_id: PeerId,
433 display_label: impl Into<String>,
434 convention: PeerIngressConvention,
435 ) -> Self {
436 Self {
437 canonical_peer_id,
438 display_label: display_label.into(),
439 signing_pubkey: None,
440 convention,
441 }
442 }
443
444 pub fn with_signing_pubkey(mut self, signing_pubkey: [u8; 32]) -> Self {
445 self.signing_pubkey = Some(signing_pubkey);
446 self
447 }
448}
449
450impl PeerIngressFact {
451 pub fn peer(
452 interaction_id: InteractionId,
453 class: PeerInputClass,
454 kind: PeerIngressKind,
455 auth: Option<PeerIngressAuthDecision>,
456 identity: PeerIngressIdentity,
457 ) -> Self {
458 let PeerIngressIdentity {
459 canonical_peer_id,
460 display_label,
461 signing_pubkey,
462 convention,
463 } = identity;
464 let display_name = PeerName::new(display_label).ok();
465 let route = Some(match &display_name {
466 Some(name) => PeerRoute::with_display_name(canonical_peer_id, name.clone()),
467 None => PeerRoute::new(canonical_peer_id),
468 });
469 Self {
470 interaction_id,
471 class,
472 kind,
473 canonical_peer_id: Some(canonical_peer_id),
474 display_name,
475 signing_pubkey,
476 route,
477 auth,
478 convention,
479 }
480 }
481
482 pub fn plain_event(
483 interaction_id: InteractionId,
484 source_name: impl Into<String>,
485 class: PeerInputClass,
486 kind: PeerIngressKind,
487 ) -> Self {
488 let source_name = source_name.into();
489 Self {
490 interaction_id,
491 class,
492 kind,
493 canonical_peer_id: None,
494 display_name: None,
495 signing_pubkey: None,
496 route: None,
497 auth: None,
498 convention: PeerIngressConvention::PlainEvent { source_name },
499 }
500 }
501
502 pub fn canonical_peer_id_string(&self) -> Option<String> {
503 self.canonical_peer_id.map(|peer_id| peer_id.as_str())
504 }
505
506 pub fn display_label(&self) -> Option<String> {
507 self.display_name.as_ref().map(PeerName::as_string)
508 }
509
510 pub fn diagnostic_label(&self) -> String {
511 self.display_label()
512 .or_else(|| self.canonical_peer_id_string())
513 .unwrap_or_else(|| "<unknown-peer-ingress>".to_string())
514 }
515
516 pub fn plain_event_source_name(&self) -> Option<&str> {
517 match &self.convention {
518 PeerIngressConvention::PlainEvent { source_name } => Some(source_name.as_str()),
519 _ => None,
520 }
521 }
522}
523
524#[derive(Debug, Clone, PartialEq, Eq)]
526pub struct PeerIngressClassification {
527 pub class: PeerInputClass,
528 pub actionable: bool,
534 pub kind: PeerIngressKind,
535 pub auth: PeerIngressAuthDecision,
536 pub lifecycle_kind: Option<PeerLifecycleKind>,
537 pub response_terminality: Option<TerminalityClass>,
538}
539
540impl PeerIngressClassification {
541 pub const fn required(class: PeerInputClass, kind: PeerIngressKind) -> Self {
542 Self {
543 class,
544 actionable: peer_input_class_actionable_grouping(class),
545 kind,
546 auth: PeerIngressAuthDecision::Required,
547 lifecycle_kind: None,
548 response_terminality: None,
549 }
550 }
551}
552
553#[derive(Debug, Clone, PartialEq)]
559pub struct PeerIngressEnvelopeFacts {
560 pub item_id: String,
561 pub from_peer: String,
562 pub from_peer_id: PeerId,
563 pub kind: PeerIngressEnvelopeKind,
564}
565
566#[derive(Debug, Clone, PartialEq)]
567pub enum PeerIngressEnvelopeKind {
568 Message {
569 body: String,
570 },
571 Request {
572 intent: String,
573 params: Value,
574 },
575 Lifecycle {
576 kind: PeerLifecycleKind,
577 params: Value,
578 },
579 Response {
580 in_reply_to: String,
581 status: ResponseStatus,
582 result: Value,
583 },
584 Ack {
585 in_reply_to: String,
586 },
587}
588
589#[derive(Debug, Clone, PartialEq, Eq)]
591pub struct PeerIngressPlainEventFacts {
592 pub source_name: String,
593 pub body: String,
594}
595
596#[derive(Debug, Clone, PartialEq, Eq)]
598pub struct PeerIngressAdmission {
599 pub classification: PeerIngressClassification,
600 pub from_peer_id: Option<PeerId>,
606 pub lifecycle_peer: Option<String>,
607 pub request_id: Option<String>,
608 pub rendered_text: String,
609}
610
611#[derive(Debug, Clone, Copy, PartialEq, Eq)]
617pub struct PeerIngressReceiveFacts {
618 pub kind: PeerIngressKind,
619 pub current_phase: PeerIngressAuthorityPhase,
620 pub auth_required: bool,
621 pub auth_exempt: bool,
622 pub trusted: bool,
623 pub queued_work_present: bool,
624 pub queue_closed: bool,
625 pub queue_capacity_available: bool,
626}
627
628#[derive(Debug, Clone, Copy, PartialEq, Eq)]
630pub struct PeerIngressReceiveAuthority {
631 pub outcome: PeerIngressReceiveOutcome,
632 pub admission_diagnostic: Option<PeerIngressAdmissionDiagnostic>,
633 pub authority_phase: PeerIngressAuthorityPhase,
634}
635
636#[derive(Debug, Clone, Copy, PartialEq, Eq)]
638pub enum PeerIngressReceiveOutcome {
639 Admitted,
640 DroppedUntrustedSender,
641 DroppedSessionClosed,
642 DroppedInboxFull,
643}
644
645#[derive(Debug, Clone, Copy, PartialEq, Eq)]
650pub struct PeerIngressDequeueFacts {
651 pub kind: PeerIngressKind,
652 pub auth: PeerIngressAuthDecision,
653 pub queued_work_remaining: bool,
654}
655
656#[derive(Debug, Clone, Copy, PartialEq, Eq)]
658pub struct PeerIngressDequeueAuthority {
659 pub authority_phase: PeerIngressAuthorityPhase,
660}
661
662pub fn render_peer_ingress_admitted_text(
668 facts: &PeerIngressEnvelopeFacts,
669 classification: &PeerIngressClassification,
670) -> String {
671 match &facts.kind {
672 PeerIngressEnvelopeKind::Message { body } => {
673 format_peer_message_projection(&facts.from_peer, body)
674 }
675 PeerIngressEnvelopeKind::Request { intent, params } => {
676 if classification.lifecycle_kind.is_some() {
677 String::new()
678 } else {
679 format_peer_request_projection(
680 facts.from_peer_id,
681 Some(&facts.from_peer),
682 facts.item_id.as_str(),
683 intent,
684 params,
685 )
686 }
687 }
688 PeerIngressEnvelopeKind::Lifecycle { .. } => String::new(),
689 PeerIngressEnvelopeKind::Response {
690 in_reply_to,
691 status,
692 result,
693 } => format_peer_response_projection(&facts.from_peer, in_reply_to, *status, result),
694 PeerIngressEnvelopeKind::Ack { in_reply_to } => {
695 format_peer_ack_projection(&facts.from_peer, in_reply_to)
696 }
697 }
698}
699
700#[derive(Debug, Clone)]
706pub struct PeerInputCandidate {
707 pub interaction: InboxInteraction,
709 pub ingress: PeerIngressFact,
712 pub lifecycle_peer: Option<String>,
714 pub response_terminality: Option<TerminalityClass>,
716}
717
718impl PeerInputCandidate {
719 pub fn new(
720 interaction: InboxInteraction,
721 ingress: PeerIngressFact,
722 lifecycle_peer: Option<String>,
723 ) -> Self {
724 Self {
725 interaction,
726 ingress,
727 lifecycle_peer,
728 response_terminality: None,
729 }
730 }
731
732 pub fn class(&self) -> PeerInputClass {
733 self.ingress.class
734 }
735
736 pub fn kind(&self) -> PeerIngressKind {
737 self.ingress.kind
738 }
739
740 pub fn auth(&self) -> Option<PeerIngressAuthDecision> {
741 self.ingress.auth
742 }
743
744 pub fn from_peer_id(&self) -> Option<PeerId> {
751 self.ingress.canonical_peer_id
752 }
753}
754
755pub type ClassifiedInboxInteraction = PeerInputCandidate;
757
758#[derive(Debug, Clone, Copy, PartialEq, Eq)]
763pub enum PeerIngressKind {
764 Message,
765 Request,
766 Response,
767 Ack,
768 PlainEvent,
769}
770
771#[derive(Debug, Clone, PartialEq, Eq)]
778pub struct PeerIngressDiagnosticDisplay(String);
779
780impl PeerIngressDiagnosticDisplay {
781 pub fn new(value: impl Into<String>) -> Self {
782 Self(value.into())
783 }
784
785 pub fn as_str(&self) -> &str {
786 &self.0
787 }
788}
789
790impl std::fmt::Display for PeerIngressDiagnosticDisplay {
791 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
792 self.0.fmt(f)
793 }
794}
795
796#[derive(Debug, Clone, Copy, PartialEq, Eq)]
802pub enum PeerIngressAdmissionDiagnostic {
803 TrustedAtAdmission,
804 UntrustedAtAdmission,
805}
806
807impl PeerIngressAdmissionDiagnostic {
808 pub const fn from_trusted(trusted: bool) -> Self {
809 if trusted {
810 Self::TrustedAtAdmission
811 } else {
812 Self::UntrustedAtAdmission
813 }
814 }
815
816 pub const fn trusted_at_admission(self) -> bool {
817 matches!(self, Self::TrustedAtAdmission)
818 }
819}
820
821#[derive(Debug, Clone, PartialEq, Eq)]
828pub struct PeerIngressEntrySnapshot {
829 pub raw_item_id: InteractionId,
831 pub interaction_id: Option<InteractionId>,
833 pub class: PeerInputClass,
835 pub actionable: bool,
839 pub kind: PeerIngressKind,
841 pub from_peer_display: Option<PeerIngressDiagnosticDisplay>,
843 pub canonical_peer_id: Option<PeerId>,
845 pub display_name: Option<PeerName>,
847 pub signing_pubkey: Option<[u8; 32]>,
849 pub route: Option<PeerRoute>,
851 pub lifecycle_peer_display: Option<PeerIngressDiagnosticDisplay>,
853 pub request_correlation_id: Option<InteractionId>,
855 pub auth: Option<PeerIngressAuthDecision>,
858 pub admission_diagnostic: Option<PeerIngressAdmissionDiagnostic>,
861 pub response_terminality: Option<TerminalityClass>,
864}
865
866#[derive(Debug, Clone, PartialEq, Eq, Default)]
872pub struct PeerIngressQueueSnapshot {
873 pub total_count: usize,
874 pub actionable_count: usize,
875 pub response_count: usize,
876 pub lifecycle_count: usize,
877 pub silent_request_count: usize,
878 pub ack_count: usize,
879 pub plain_event_count: usize,
880 pub queued_entries: Vec<PeerIngressEntrySnapshot>,
881}
882
883#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
888pub enum PeerIngressAuthorityPhase {
889 #[default]
890 Absent,
891 Received,
892 Dropped,
893 Delivered,
894}
895
896#[derive(Debug, Clone, PartialEq, Eq)]
901pub struct PeerIngressRuntimeSnapshot {
902 pub self_peer_id: crate::comms::PeerId,
904 pub auth_required: bool,
906 pub authority_phase: PeerIngressAuthorityPhase,
908 pub trusted_peers: Vec<TrustedPeerDescriptor>,
910 pub submission_queue_len: usize,
912 pub queue: PeerIngressQueueSnapshot,
914}
915
916#[cfg(test)]
917#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
918mod tests {
919 use super::*;
920
921 #[test]
922 fn interaction_id_json_roundtrip() {
923 let id = InteractionId(Uuid::new_v4());
924 let json = serde_json::to_string(&id).unwrap();
925 let parsed: InteractionId = serde_json::from_str(&json).unwrap();
926 assert_eq!(id, parsed);
927 }
928
929 #[test]
930 fn interaction_content_message_json_roundtrip() {
931 let content = InteractionContent::Message {
932 body: "hello".to_string(),
933 blocks: None,
934 };
935 let json = serde_json::to_value(&content).unwrap();
936 assert_eq!(json["type"], "message");
937 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
938 assert_eq!(content, parsed);
939 }
940
941 #[test]
942 fn interaction_content_request_json_roundtrip() {
943 let content = InteractionContent::Request {
944 intent: "review".to_string(),
945 params: serde_json::json!({"pr": 42}),
946 blocks: None,
947 };
948 let json = serde_json::to_value(&content).unwrap();
949 assert_eq!(json["type"], "request");
950 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
951 assert_eq!(content, parsed);
952 }
953
954 #[test]
955 fn interaction_content_response_json_roundtrip() {
956 let id = InteractionId(Uuid::new_v4());
957 let content = InteractionContent::Response {
958 in_reply_to: id,
959 status: ResponseStatus::Completed,
960 result: serde_json::json!({"ok": true}),
961 blocks: None,
962 };
963 let json = serde_json::to_value(&content).unwrap();
964 assert_eq!(json["type"], "response");
965 assert_eq!(json["status"], "completed");
966 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
967 assert_eq!(content, parsed);
968 }
969
970 #[test]
971 fn response_status_json_roundtrip_all_variants() {
972 for (variant, expected_str) in [
973 (ResponseStatus::Accepted, "accepted"),
974 (ResponseStatus::Completed, "completed"),
975 (ResponseStatus::Failed, "failed"),
976 ] {
977 let json = serde_json::to_value(variant).unwrap();
978 assert_eq!(json, expected_str);
979 let parsed: ResponseStatus = serde_json::from_value(json).unwrap();
980 assert_eq!(variant, parsed);
981 }
982 }
983
984 #[test]
985 fn interaction_message_with_blocks_roundtrip() {
986 let content = InteractionContent::Message {
987 body: "hello".to_string(),
988 blocks: Some(vec![
989 ContentBlock::Text {
990 text: "hello".to_string(),
991 },
992 ContentBlock::Image {
993 media_type: "image/png".to_string(),
994 data: "iVBORw0KGgo=".into(),
995 },
996 ]),
997 };
998 let json = serde_json::to_value(&content).unwrap();
999 assert_eq!(json["type"], "message");
1000 assert!(json["blocks"].is_array());
1001 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
1002 assert_eq!(content, parsed);
1003 }
1004
1005 #[test]
1006 fn inbox_interaction_preserves_runtime_hints() {
1007 let interaction = InboxInteraction {
1008 id: InteractionId(Uuid::new_v4()),
1009 from_route: None,
1010 from: "event:webhook".into(),
1011 content: InteractionContent::Message {
1012 body: "hello".into(),
1013 blocks: None,
1014 },
1015 rendered_text: "External event via webhook: hello".into(),
1016 handling_mode: HandlingMode::Steer,
1017 render_metadata: Some(RenderMetadata {
1018 class: crate::types::RenderClass::SystemNotice,
1019 salience: crate::types::RenderSalience::Urgent,
1020 }),
1021 sender_taint: None,
1022 };
1023
1024 assert_eq!(interaction.handling_mode, HandlingMode::Steer);
1025 assert!(interaction.render_metadata.is_some());
1026 }
1027
1028 #[test]
1029 fn interaction_message_without_blocks_compat() {
1030 let old_json = r#"{"type":"message","body":"hello"}"#;
1032 let parsed: InteractionContent = serde_json::from_str(old_json).unwrap();
1033 match parsed {
1034 InteractionContent::Message { body, blocks } => {
1035 assert_eq!(body, "hello");
1036 assert_eq!(blocks, None);
1037 }
1038 other => panic!("Expected Message, got {other:?}"),
1039 }
1040
1041 let content = InteractionContent::Message {
1043 body: "test".to_string(),
1044 blocks: None,
1045 };
1046 let json = serde_json::to_string(&content).unwrap();
1047 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1048 assert!(
1049 value.get("blocks").is_none(),
1050 "blocks: None should not appear in JSON"
1051 );
1052 }
1053
1054 #[test]
1061 fn actionable_grouping_mirror_matches_machine_grouping_for_all_variants() {
1062 for (class, expected_actionable) in [
1065 (PeerInputClass::ActionableMessage, true),
1066 (PeerInputClass::ActionableRequest, true),
1067 (PeerInputClass::ResponseProgress, true),
1068 (PeerInputClass::ResponseTerminal, true),
1069 (PeerInputClass::PlainEvent, true),
1070 (PeerInputClass::PeerLifecycleKickoffFailed, true),
1071 (PeerInputClass::PeerLifecycleKickoffCancelled, true),
1072 (PeerInputClass::PeerLifecycleAdded, false),
1073 (PeerInputClass::PeerLifecycleRetired, false),
1074 (PeerInputClass::PeerLifecycleUnwired, false),
1075 (PeerInputClass::SilentRequest, false),
1076 (PeerInputClass::Ack, false),
1077 ] {
1078 assert_eq!(
1079 peer_input_class_actionable_grouping(class),
1080 expected_actionable,
1081 "actionable grouping verdict drifted for {class:?}"
1082 );
1083 }
1084 fn assert_variant_covered(class: PeerInputClass) {
1088 match class {
1089 PeerInputClass::ActionableMessage
1090 | PeerInputClass::ActionableRequest
1091 | PeerInputClass::ResponseProgress
1092 | PeerInputClass::ResponseTerminal
1093 | PeerInputClass::PlainEvent
1094 | PeerInputClass::PeerLifecycleKickoffFailed
1095 | PeerInputClass::PeerLifecycleKickoffCancelled
1096 | PeerInputClass::PeerLifecycleAdded
1097 | PeerInputClass::PeerLifecycleRetired
1098 | PeerInputClass::PeerLifecycleUnwired
1099 | PeerInputClass::SilentRequest
1100 | PeerInputClass::Ack => (),
1101 }
1102 }
1103 assert_variant_covered(PeerInputClass::Ack);
1104 }
1105}