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))]
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
31pub struct ObjectiveId(#[cfg_attr(feature = "schema", schemars(with = "String"))] pub Uuid);
32
33impl ObjectiveId {
34 #[must_use]
35 pub fn new() -> Self {
36 Self(Uuid::new_v4())
37 }
38}
39
40impl Default for ObjectiveId {
41 fn default() -> Self {
42 Self::new()
43 }
44}
45
46impl std::fmt::Display for ObjectiveId {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 self.0.fmt(f)
49 }
50}
51
52#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum ResponseStatus {
59 Accepted,
60 Completed,
61 Failed,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
71#[non_exhaustive]
72pub enum TerminalityClass {
73 Progress,
74 Terminal { disposition: TerminalDisposition },
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
78#[non_exhaustive]
79pub enum TerminalDisposition {
80 Completed,
81 Failed,
82}
83
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89#[serde(tag = "type", rename_all = "snake_case")]
90pub enum InteractionContent {
91 Message {
93 body: String,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 blocks: Option<Vec<ContentBlock>>,
97 },
98 IncarnationFencedMessage {
102 body: String,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 blocks: Option<Vec<ContentBlock>>,
105 expected_recipient: crate::comms::PeerRecipientIncarnation,
106 },
107 Request {
109 intent: String,
110 params: Value,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 blocks: Option<Vec<ContentBlock>>,
113 },
114 Response {
116 in_reply_to: InteractionId,
117 status: ResponseStatus,
118 result: Value,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 blocks: Option<Vec<ContentBlock>>,
121 },
122}
123
124#[derive(Debug, Clone)]
126pub struct InboxInteraction {
127 pub id: InteractionId,
129 pub from_route: Option<PeerId>,
132 pub from: String,
134 pub content: InteractionContent,
136 pub rendered_text: String,
138 pub handling_mode: HandlingMode,
140 pub render_metadata: Option<RenderMetadata>,
142 pub sender_taint: Option<SenderContentTaint>,
148 pub objective_id: Option<ObjectiveId>,
151}
152
153pub fn format_external_event_projection(source_name: &str, body: Option<&str>) -> String {
159 let label = format!("External event via {source_name}");
160 let body = body.map(str::trim).filter(|body| !body.is_empty());
161
162 match body {
163 Some(body) => format!("{label}: {body}"),
164 None => label,
165 }
166}
167
168pub fn format_peer_message_projection(from_peer: &str, body: &str) -> String {
170 format!("Peer message from {from_peer}:\n{body}")
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct SendResponseCallProjection {
180 pub peer_id: PeerId,
181 pub display_name: Option<String>,
182 pub in_reply_to: String,
183}
184
185impl SendResponseCallProjection {
186 pub const TOOL_NAME: &'static str = "send_response";
187 pub const PEER_ID_FIELD: &'static str = "peer_id";
188 pub const DISPLAY_NAME_FIELD: &'static str = "display_name";
189 pub const IN_REPLY_TO_FIELD: &'static str = "in_reply_to";
190 pub const STATUS_FIELD: &'static str = "status";
191 pub const RESULT_FIELD: &'static str = "result";
192
193 pub fn new(
194 peer_id: PeerId,
195 display_name: Option<&str>,
196 in_reply_to: impl Into<String>,
197 ) -> Self {
198 Self {
199 peer_id,
200 display_name: display_name
201 .map(str::trim)
202 .filter(|name| !name.is_empty())
203 .map(ToOwned::to_owned),
204 in_reply_to: in_reply_to.into(),
205 }
206 }
207
208 pub fn completed_example_args(&self) -> Value {
214 let mut args = serde_json::Map::new();
215 args.insert(
216 Self::PEER_ID_FIELD.to_string(),
217 Value::String(self.peer_id.to_string()),
218 );
219 if let Some(display_name) = &self.display_name {
220 args.insert(
221 Self::DISPLAY_NAME_FIELD.to_string(),
222 Value::String(display_name.clone()),
223 );
224 }
225 args.insert(
226 Self::IN_REPLY_TO_FIELD.to_string(),
227 Value::String(self.in_reply_to.clone()),
228 );
229 args.insert(
230 Self::STATUS_FIELD.to_string(),
231 Value::String("completed".to_string()),
232 );
233 Value::Object(args)
234 }
235
236 pub fn instruction_text(&self) -> String {
237 let args = serde_json::to_string(&self.completed_example_args())
238 .unwrap_or_else(|_| "{}".to_string());
239 format!(
240 "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.",
241 Self::TOOL_NAME
242 )
243 }
244}
245
246pub fn format_peer_request_projection(
248 from_peer_id: PeerId,
249 display_name: Option<&str>,
250 request_id: impl std::fmt::Display,
251 intent: &str,
252 params: &Value,
253) -> String {
254 let params_str = if params.is_null() || matches!(params, Value::Object(map) if map.is_empty()) {
255 String::new()
256 } else {
257 format!(
258 "\nParams: {}",
259 serde_json::to_string_pretty(params).unwrap_or_default()
260 )
261 };
262 let request_id = request_id.to_string();
263 let display_suffix = display_name
264 .map(str::trim)
265 .filter(|name| !name.is_empty())
266 .map(|name| format!(" (display_name: {name})"))
267 .unwrap_or_default();
268 let response_call =
269 SendResponseCallProjection::new(from_peer_id, display_name, request_id.clone());
270
271 format!(
272 "Peer request from peer_id {from_peer_id}{display_suffix} (id: {request_id})\n\
273 Intent: {intent}{params_str}\n\
274 Request ID: {request_id}\n\
275 \n\
276 This is a correlated peer request. {} \
277 Do not answer this request with send_message.",
278 response_call.instruction_text()
279 )
280}
281
282pub fn format_peer_response_projection(
284 from_peer: &str,
285 in_reply_to: impl std::fmt::Display,
286 status: ResponseStatus,
287 result: &Value,
288) -> String {
289 let status_str = match status {
290 ResponseStatus::Accepted => "accepted",
291 ResponseStatus::Completed => "completed",
292 ResponseStatus::Failed => "failed",
293 };
294 let result_str = if result.is_null() || matches!(result, Value::Object(map) if map.is_empty()) {
295 String::new()
296 } else {
297 format!(
298 "\nResult: {}",
299 serde_json::to_string_pretty(result).unwrap_or_default()
300 )
301 };
302
303 format!(
304 "Peer response from {from_peer} (to request: {in_reply_to})\n\
305 Status: {status_str}{result_str}"
306 )
307}
308
309pub fn format_peer_ack_projection(from_peer: &str, in_reply_to: impl std::fmt::Display) -> String {
311 format!("Peer ack from {from_peer} (to request: {in_reply_to})")
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319pub enum PeerInputClass {
320 ActionableMessage,
322 ActionableRequest,
324 ResponseProgress,
326 ResponseTerminal,
328 PeerLifecycleAdded,
330 PeerLifecycleRetired,
332 PeerLifecycleUnwired,
334 PeerLifecycleKickoffFailed,
336 PeerLifecycleKickoffCancelled,
338 SilentRequest,
340 Ack,
342 PlainEvent,
344}
345
346const fn peer_input_class_actionable_grouping(class: PeerInputClass) -> bool {
356 matches!(
357 class,
358 PeerInputClass::ActionableMessage
359 | PeerInputClass::ActionableRequest
360 | PeerInputClass::ResponseProgress
361 | PeerInputClass::ResponseTerminal
362 | PeerInputClass::PlainEvent
363 | PeerInputClass::PeerLifecycleKickoffFailed
364 | PeerInputClass::PeerLifecycleKickoffCancelled
365 )
366}
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
370pub enum PeerIngressAuthExemption {
371 SupervisorBridge,
373}
374
375impl PeerIngressAuthExemption {
376 pub const fn intent(self) -> &'static str {
377 match self {
378 Self::SupervisorBridge => SUPERVISOR_BRIDGE_INTENT,
379 }
380 }
381
382 pub fn matches_intent(self, intent: &str) -> bool {
383 self.intent() == intent
384 }
385}
386
387#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
389pub enum PeerIngressAuthDecision {
390 Required,
392 Exempt(PeerIngressAuthExemption),
394}
395
396impl PeerIngressAuthDecision {
397 pub const fn is_exempt(self) -> bool {
398 matches!(self, Self::Exempt(_))
399 }
400}
401
402#[derive(Debug, Clone, PartialEq, Eq)]
408pub enum PeerIngressConvention {
409 Message,
410 Request {
411 request_id: String,
412 intent: String,
413 },
414 Response {
415 in_reply_to: InteractionId,
416 status: ResponseStatus,
417 },
418 Ack {
419 in_reply_to: InteractionId,
420 },
421 Lifecycle {
422 kind: PeerLifecycleKind,
423 peer: String,
424 },
425 PlainEvent {
426 source_name: String,
427 },
428}
429
430#[derive(Debug, Clone, PartialEq, Eq)]
436pub struct PeerIngressFact {
437 pub interaction_id: InteractionId,
439 pub class: PeerInputClass,
441 pub kind: PeerIngressKind,
443 pub canonical_peer_id: Option<PeerId>,
445 pub display_name: Option<PeerName>,
447 pub signing_pubkey: Option<[u8; 32]>,
449 pub route: Option<PeerRoute>,
451 pub declared_reply_endpoint: Option<crate::comms::PeerAddress>,
458 pub auth: Option<PeerIngressAuthDecision>,
460 pub convention: PeerIngressConvention,
462}
463
464#[derive(Debug, Clone, PartialEq, Eq)]
466pub struct PeerIngressIdentity {
467 pub canonical_peer_id: PeerId,
468 pub display_label: String,
469 pub signing_pubkey: Option<[u8; 32]>,
470 pub convention: PeerIngressConvention,
471}
472
473impl PeerIngressIdentity {
474 pub fn new(
475 canonical_peer_id: PeerId,
476 display_label: impl Into<String>,
477 convention: PeerIngressConvention,
478 ) -> Self {
479 Self {
480 canonical_peer_id,
481 display_label: display_label.into(),
482 signing_pubkey: None,
483 convention,
484 }
485 }
486
487 pub fn with_signing_pubkey(mut self, signing_pubkey: [u8; 32]) -> Self {
488 self.signing_pubkey = Some(signing_pubkey);
489 self
490 }
491}
492
493impl PeerIngressFact {
494 pub fn peer(
495 interaction_id: InteractionId,
496 class: PeerInputClass,
497 kind: PeerIngressKind,
498 auth: Option<PeerIngressAuthDecision>,
499 identity: PeerIngressIdentity,
500 ) -> Self {
501 let PeerIngressIdentity {
502 canonical_peer_id,
503 display_label,
504 signing_pubkey,
505 convention,
506 } = identity;
507 let display_name = PeerName::new(display_label).ok();
508 let route = Some(match &display_name {
509 Some(name) => PeerRoute::with_display_name(canonical_peer_id, name.clone()),
510 None => PeerRoute::new(canonical_peer_id),
511 });
512 Self {
513 interaction_id,
514 class,
515 kind,
516 canonical_peer_id: Some(canonical_peer_id),
517 display_name,
518 signing_pubkey,
519 route,
520 declared_reply_endpoint: None,
521 auth,
522 convention,
523 }
524 }
525
526 pub fn plain_event(
527 interaction_id: InteractionId,
528 source_name: impl Into<String>,
529 class: PeerInputClass,
530 kind: PeerIngressKind,
531 ) -> Self {
532 let source_name = source_name.into();
533 Self {
534 interaction_id,
535 class,
536 kind,
537 canonical_peer_id: None,
538 display_name: None,
539 signing_pubkey: None,
540 route: None,
541 declared_reply_endpoint: None,
542 auth: None,
543 convention: PeerIngressConvention::PlainEvent { source_name },
544 }
545 }
546
547 pub fn with_declared_reply_endpoint(
552 mut self,
553 endpoint: Option<crate::comms::PeerAddress>,
554 ) -> Self {
555 self.declared_reply_endpoint = endpoint;
556 self
557 }
558
559 pub fn canonical_peer_id_string(&self) -> Option<String> {
560 self.canonical_peer_id.map(|peer_id| peer_id.as_str())
561 }
562
563 pub fn display_label(&self) -> Option<String> {
564 self.display_name.as_ref().map(PeerName::as_string)
565 }
566
567 pub fn diagnostic_label(&self) -> String {
568 self.display_label()
569 .or_else(|| self.canonical_peer_id_string())
570 .unwrap_or_else(|| "<unknown-peer-ingress>".to_string())
571 }
572
573 pub fn plain_event_source_name(&self) -> Option<&str> {
574 match &self.convention {
575 PeerIngressConvention::PlainEvent { source_name } => Some(source_name.as_str()),
576 _ => None,
577 }
578 }
579}
580
581#[derive(Debug, Clone, PartialEq, Eq)]
583pub struct PeerIngressClassification {
584 pub class: PeerInputClass,
585 pub actionable: bool,
591 pub kind: PeerIngressKind,
592 pub auth: PeerIngressAuthDecision,
593 pub lifecycle_kind: Option<PeerLifecycleKind>,
594 pub response_terminality: Option<TerminalityClass>,
595}
596
597impl PeerIngressClassification {
598 pub const fn required(class: PeerInputClass, kind: PeerIngressKind) -> Self {
599 Self {
600 class,
601 actionable: peer_input_class_actionable_grouping(class),
602 kind,
603 auth: PeerIngressAuthDecision::Required,
604 lifecycle_kind: None,
605 response_terminality: None,
606 }
607 }
608}
609
610#[derive(Debug, Clone, PartialEq)]
616pub struct PeerIngressEnvelopeFacts {
617 pub item_id: String,
618 pub from_peer: String,
619 pub from_peer_id: PeerId,
620 pub kind: PeerIngressEnvelopeKind,
621}
622
623#[derive(Debug, Clone, PartialEq)]
624pub enum PeerIngressEnvelopeKind {
625 Message {
626 body: String,
627 },
628 Request {
629 intent: String,
630 params: Value,
631 },
632 Lifecycle {
633 kind: PeerLifecycleKind,
634 params: Value,
635 },
636 Response {
637 in_reply_to: String,
638 status: ResponseStatus,
639 result: Value,
640 },
641 Ack {
642 in_reply_to: String,
643 },
644}
645
646#[derive(Debug, Clone, PartialEq, Eq)]
648pub struct PeerIngressPlainEventFacts {
649 pub source_name: String,
650 pub body: String,
651}
652
653#[derive(Debug, Clone, PartialEq, Eq)]
655pub struct PeerIngressAdmission {
656 pub classification: PeerIngressClassification,
657 pub from_peer_id: Option<PeerId>,
663 pub lifecycle_peer: Option<String>,
664 pub request_id: Option<String>,
665 pub rendered_text: String,
666}
667
668#[derive(Debug, Clone, Copy, PartialEq, Eq)]
674pub struct PeerIngressReceiveFacts {
675 pub kind: PeerIngressKind,
676 pub current_phase: PeerIngressAuthorityPhase,
677 pub auth_required: bool,
678 pub auth_exempt: bool,
679 pub trusted: bool,
680 pub queued_work_present: bool,
681 pub queue_closed: bool,
682 pub queue_capacity_available: bool,
683}
684
685#[derive(Debug, Clone, Copy, PartialEq, Eq)]
687pub struct PeerIngressReceiveAuthority {
688 pub outcome: PeerIngressReceiveOutcome,
689 pub admission_diagnostic: Option<PeerIngressAdmissionDiagnostic>,
690 pub authority_phase: PeerIngressAuthorityPhase,
691}
692
693#[derive(Debug, Clone, Copy, PartialEq, Eq)]
695pub enum PeerIngressReceiveOutcome {
696 Admitted,
697 DroppedUntrustedSender,
698 DroppedSessionClosed,
699 DroppedInboxFull,
700}
701
702#[derive(Debug, Clone, Copy, PartialEq, Eq)]
707pub struct PeerIngressDequeueFacts {
708 pub kind: PeerIngressKind,
709 pub auth: PeerIngressAuthDecision,
710 pub queued_work_remaining: bool,
711}
712
713#[derive(Debug, Clone, Copy, PartialEq, Eq)]
715pub struct PeerIngressDequeueAuthority {
716 pub authority_phase: PeerIngressAuthorityPhase,
717}
718
719pub fn render_peer_ingress_admitted_text(
725 facts: &PeerIngressEnvelopeFacts,
726 classification: &PeerIngressClassification,
727) -> String {
728 match &facts.kind {
729 PeerIngressEnvelopeKind::Message { body } => {
730 format_peer_message_projection(&facts.from_peer, body)
731 }
732 PeerIngressEnvelopeKind::Request { intent, params } => {
733 if classification.lifecycle_kind.is_some() {
734 String::new()
735 } else {
736 format_peer_request_projection(
737 facts.from_peer_id,
738 Some(&facts.from_peer),
739 facts.item_id.as_str(),
740 intent,
741 params,
742 )
743 }
744 }
745 PeerIngressEnvelopeKind::Lifecycle { .. } => String::new(),
746 PeerIngressEnvelopeKind::Response {
747 in_reply_to,
748 status,
749 result,
750 } => format_peer_response_projection(&facts.from_peer, in_reply_to, *status, result),
751 PeerIngressEnvelopeKind::Ack { in_reply_to } => {
752 format_peer_ack_projection(&facts.from_peer, in_reply_to)
753 }
754 }
755}
756
757#[derive(Debug, Clone)]
763pub struct PeerInputCandidate {
764 pub interaction: InboxInteraction,
766 pub ingress: PeerIngressFact,
769 pub lifecycle_peer: Option<String>,
771 pub response_terminality: Option<TerminalityClass>,
773}
774
775impl PeerInputCandidate {
776 pub fn new(
777 interaction: InboxInteraction,
778 ingress: PeerIngressFact,
779 lifecycle_peer: Option<String>,
780 ) -> Self {
781 Self {
782 interaction,
783 ingress,
784 lifecycle_peer,
785 response_terminality: None,
786 }
787 }
788
789 pub fn class(&self) -> PeerInputClass {
790 self.ingress.class
791 }
792
793 pub fn kind(&self) -> PeerIngressKind {
794 self.ingress.kind
795 }
796
797 pub fn auth(&self) -> Option<PeerIngressAuthDecision> {
798 self.ingress.auth
799 }
800
801 pub fn from_peer_id(&self) -> Option<PeerId> {
808 self.ingress.canonical_peer_id
809 }
810}
811
812pub type ClassifiedInboxInteraction = PeerInputCandidate;
814
815#[derive(Debug, Clone, Copy, PartialEq, Eq)]
820pub enum PeerIngressKind {
821 Message,
822 Request,
823 Response,
824 Ack,
825 PlainEvent,
826}
827
828#[derive(Debug, Clone, PartialEq, Eq)]
835pub struct PeerIngressDiagnosticDisplay(String);
836
837impl PeerIngressDiagnosticDisplay {
838 pub fn new(value: impl Into<String>) -> Self {
839 Self(value.into())
840 }
841
842 pub fn as_str(&self) -> &str {
843 &self.0
844 }
845}
846
847impl std::fmt::Display for PeerIngressDiagnosticDisplay {
848 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
849 self.0.fmt(f)
850 }
851}
852
853#[derive(Debug, Clone, Copy, PartialEq, Eq)]
859pub enum PeerIngressAdmissionDiagnostic {
860 TrustedAtAdmission,
861 UntrustedAtAdmission,
862}
863
864impl PeerIngressAdmissionDiagnostic {
865 pub const fn from_trusted(trusted: bool) -> Self {
866 if trusted {
867 Self::TrustedAtAdmission
868 } else {
869 Self::UntrustedAtAdmission
870 }
871 }
872
873 pub const fn trusted_at_admission(self) -> bool {
874 matches!(self, Self::TrustedAtAdmission)
875 }
876}
877
878#[derive(Debug, Clone, PartialEq, Eq)]
885pub struct PeerIngressEntrySnapshot {
886 pub raw_item_id: InteractionId,
888 pub interaction_id: Option<InteractionId>,
890 pub class: PeerInputClass,
892 pub actionable: bool,
896 pub kind: PeerIngressKind,
898 pub from_peer_display: Option<PeerIngressDiagnosticDisplay>,
900 pub canonical_peer_id: Option<PeerId>,
902 pub display_name: Option<PeerName>,
904 pub signing_pubkey: Option<[u8; 32]>,
906 pub route: Option<PeerRoute>,
908 pub lifecycle_peer_display: Option<PeerIngressDiagnosticDisplay>,
910 pub request_correlation_id: Option<InteractionId>,
912 pub auth: Option<PeerIngressAuthDecision>,
915 pub admission_diagnostic: Option<PeerIngressAdmissionDiagnostic>,
918 pub response_terminality: Option<TerminalityClass>,
921}
922
923#[derive(Debug, Clone, PartialEq, Eq, Default)]
929pub struct PeerIngressQueueSnapshot {
930 pub total_count: usize,
931 pub actionable_count: usize,
932 pub response_count: usize,
933 pub lifecycle_count: usize,
934 pub silent_request_count: usize,
935 pub ack_count: usize,
936 pub plain_event_count: usize,
937 pub queued_entries: Vec<PeerIngressEntrySnapshot>,
938}
939
940#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
945pub enum PeerIngressAuthorityPhase {
946 #[default]
947 Absent,
948 Received,
949 Dropped,
950 Delivered,
951}
952
953#[derive(Debug, Clone, PartialEq, Eq)]
958pub struct PeerIngressRuntimeSnapshot {
959 pub self_peer_id: crate::comms::PeerId,
961 pub auth_required: bool,
963 pub authority_phase: PeerIngressAuthorityPhase,
965 pub trusted_peers: Vec<TrustedPeerDescriptor>,
967 pub submission_queue_len: usize,
969 pub queue: PeerIngressQueueSnapshot,
971}
972
973#[cfg(test)]
974#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
975mod tests {
976 use super::*;
977
978 #[test]
979 fn interaction_id_json_roundtrip() {
980 let id = InteractionId(Uuid::new_v4());
981 let json = serde_json::to_string(&id).unwrap();
982 let parsed: InteractionId = serde_json::from_str(&json).unwrap();
983 assert_eq!(id, parsed);
984 }
985
986 #[test]
987 fn interaction_content_message_json_roundtrip() {
988 let content = InteractionContent::Message {
989 body: "hello".to_string(),
990 blocks: None,
991 };
992 let json = serde_json::to_value(&content).unwrap();
993 assert_eq!(json["type"], "message");
994 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
995 assert_eq!(content, parsed);
996 }
997
998 #[test]
999 fn interaction_content_request_json_roundtrip() {
1000 let content = InteractionContent::Request {
1001 intent: "review".to_string(),
1002 params: serde_json::json!({"pr": 42}),
1003 blocks: None,
1004 };
1005 let json = serde_json::to_value(&content).unwrap();
1006 assert_eq!(json["type"], "request");
1007 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
1008 assert_eq!(content, parsed);
1009 }
1010
1011 #[test]
1012 fn interaction_content_response_json_roundtrip() {
1013 let id = InteractionId(Uuid::new_v4());
1014 let content = InteractionContent::Response {
1015 in_reply_to: id,
1016 status: ResponseStatus::Completed,
1017 result: serde_json::json!({"ok": true}),
1018 blocks: None,
1019 };
1020 let json = serde_json::to_value(&content).unwrap();
1021 assert_eq!(json["type"], "response");
1022 assert_eq!(json["status"], "completed");
1023 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
1024 assert_eq!(content, parsed);
1025 }
1026
1027 #[test]
1028 fn response_status_json_roundtrip_all_variants() {
1029 for (variant, expected_str) in [
1030 (ResponseStatus::Accepted, "accepted"),
1031 (ResponseStatus::Completed, "completed"),
1032 (ResponseStatus::Failed, "failed"),
1033 ] {
1034 let json = serde_json::to_value(variant).unwrap();
1035 assert_eq!(json, expected_str);
1036 let parsed: ResponseStatus = serde_json::from_value(json).unwrap();
1037 assert_eq!(variant, parsed);
1038 }
1039 }
1040
1041 #[test]
1042 fn interaction_message_with_blocks_roundtrip() {
1043 let content = InteractionContent::Message {
1044 body: "hello".to_string(),
1045 blocks: Some(vec![
1046 ContentBlock::Text {
1047 text: "hello".to_string(),
1048 },
1049 ContentBlock::Image {
1050 media_type: "image/png".to_string(),
1051 data: "iVBORw0KGgo=".into(),
1052 },
1053 ]),
1054 };
1055 let json = serde_json::to_value(&content).unwrap();
1056 assert_eq!(json["type"], "message");
1057 assert!(json["blocks"].is_array());
1058 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
1059 assert_eq!(content, parsed);
1060 }
1061
1062 #[test]
1063 fn inbox_interaction_preserves_runtime_hints() {
1064 let interaction = InboxInteraction {
1065 objective_id: None,
1066 id: InteractionId(Uuid::new_v4()),
1067 from_route: None,
1068 from: "event:webhook".into(),
1069 content: InteractionContent::Message {
1070 body: "hello".into(),
1071 blocks: None,
1072 },
1073 rendered_text: "External event via webhook: hello".into(),
1074 handling_mode: HandlingMode::Steer,
1075 render_metadata: Some(RenderMetadata {
1076 class: crate::types::RenderClass::SystemNotice,
1077 salience: crate::types::RenderSalience::Urgent,
1078 }),
1079 sender_taint: None,
1080 };
1081
1082 assert_eq!(interaction.handling_mode, HandlingMode::Steer);
1083 assert!(interaction.render_metadata.is_some());
1084 }
1085
1086 #[test]
1087 fn interaction_message_without_blocks_compat() {
1088 let old_json = r#"{"type":"message","body":"hello"}"#;
1090 let parsed: InteractionContent = serde_json::from_str(old_json).unwrap();
1091 match parsed {
1092 InteractionContent::Message { body, blocks } => {
1093 assert_eq!(body, "hello");
1094 assert_eq!(blocks, None);
1095 }
1096 other => panic!("Expected Message, got {other:?}"),
1097 }
1098
1099 let content = InteractionContent::Message {
1101 body: "test".to_string(),
1102 blocks: None,
1103 };
1104 let json = serde_json::to_string(&content).unwrap();
1105 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1106 assert!(
1107 value.get("blocks").is_none(),
1108 "blocks: None should not appear in JSON"
1109 );
1110 }
1111
1112 #[test]
1119 fn actionable_grouping_mirror_matches_machine_grouping_for_all_variants() {
1120 for (class, expected_actionable) in [
1123 (PeerInputClass::ActionableMessage, true),
1124 (PeerInputClass::ActionableRequest, true),
1125 (PeerInputClass::ResponseProgress, true),
1126 (PeerInputClass::ResponseTerminal, true),
1127 (PeerInputClass::PlainEvent, true),
1128 (PeerInputClass::PeerLifecycleKickoffFailed, true),
1129 (PeerInputClass::PeerLifecycleKickoffCancelled, true),
1130 (PeerInputClass::PeerLifecycleAdded, false),
1131 (PeerInputClass::PeerLifecycleRetired, false),
1132 (PeerInputClass::PeerLifecycleUnwired, false),
1133 (PeerInputClass::SilentRequest, false),
1134 (PeerInputClass::Ack, false),
1135 ] {
1136 assert_eq!(
1137 peer_input_class_actionable_grouping(class),
1138 expected_actionable,
1139 "actionable grouping verdict drifted for {class:?}"
1140 );
1141 }
1142 fn assert_variant_covered(class: PeerInputClass) {
1146 match class {
1147 PeerInputClass::ActionableMessage
1148 | PeerInputClass::ActionableRequest
1149 | PeerInputClass::ResponseProgress
1150 | PeerInputClass::ResponseTerminal
1151 | PeerInputClass::PlainEvent
1152 | PeerInputClass::PeerLifecycleKickoffFailed
1153 | PeerInputClass::PeerLifecycleKickoffCancelled
1154 | PeerInputClass::PeerLifecycleAdded
1155 | PeerInputClass::PeerLifecycleRetired
1156 | PeerInputClass::PeerLifecycleUnwired
1157 | PeerInputClass::SilentRequest
1158 | PeerInputClass::Ack => (),
1159 }
1160 }
1161 assert_variant_covered(PeerInputClass::Ack);
1162 }
1163}