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 Request {
100 intent: String,
101 params: Value,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 blocks: Option<Vec<ContentBlock>>,
104 },
105 Response {
107 in_reply_to: InteractionId,
108 status: ResponseStatus,
109 result: Value,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 blocks: Option<Vec<ContentBlock>>,
112 },
113}
114
115#[derive(Debug, Clone)]
117pub struct InboxInteraction {
118 pub id: InteractionId,
120 pub from_route: Option<PeerId>,
123 pub from: String,
125 pub content: InteractionContent,
127 pub rendered_text: String,
129 pub handling_mode: HandlingMode,
131 pub render_metadata: Option<RenderMetadata>,
133 pub sender_taint: Option<SenderContentTaint>,
139 pub objective_id: Option<ObjectiveId>,
142}
143
144pub fn format_external_event_projection(source_name: &str, body: Option<&str>) -> String {
150 let label = format!("External event via {source_name}");
151 let body = body.map(str::trim).filter(|body| !body.is_empty());
152
153 match body {
154 Some(body) => format!("{label}: {body}"),
155 None => label,
156 }
157}
158
159pub fn format_peer_message_projection(from_peer: &str, body: &str) -> String {
161 format!("Peer message from {from_peer}:\n{body}")
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct SendResponseCallProjection {
171 pub peer_id: PeerId,
172 pub display_name: Option<String>,
173 pub in_reply_to: String,
174}
175
176impl SendResponseCallProjection {
177 pub const TOOL_NAME: &'static str = "send_response";
178 pub const PEER_ID_FIELD: &'static str = "peer_id";
179 pub const DISPLAY_NAME_FIELD: &'static str = "display_name";
180 pub const IN_REPLY_TO_FIELD: &'static str = "in_reply_to";
181 pub const STATUS_FIELD: &'static str = "status";
182 pub const RESULT_FIELD: &'static str = "result";
183
184 pub fn new(
185 peer_id: PeerId,
186 display_name: Option<&str>,
187 in_reply_to: impl Into<String>,
188 ) -> Self {
189 Self {
190 peer_id,
191 display_name: display_name
192 .map(str::trim)
193 .filter(|name| !name.is_empty())
194 .map(ToOwned::to_owned),
195 in_reply_to: in_reply_to.into(),
196 }
197 }
198
199 pub fn completed_example_args(&self) -> Value {
205 let mut args = serde_json::Map::new();
206 args.insert(
207 Self::PEER_ID_FIELD.to_string(),
208 Value::String(self.peer_id.to_string()),
209 );
210 if let Some(display_name) = &self.display_name {
211 args.insert(
212 Self::DISPLAY_NAME_FIELD.to_string(),
213 Value::String(display_name.clone()),
214 );
215 }
216 args.insert(
217 Self::IN_REPLY_TO_FIELD.to_string(),
218 Value::String(self.in_reply_to.clone()),
219 );
220 args.insert(
221 Self::STATUS_FIELD.to_string(),
222 Value::String("completed".to_string()),
223 );
224 Value::Object(args)
225 }
226
227 pub fn instruction_text(&self) -> String {
228 let args = serde_json::to_string(&self.completed_example_args())
229 .unwrap_or_else(|_| "{}".to_string());
230 format!(
231 "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.",
232 Self::TOOL_NAME
233 )
234 }
235}
236
237pub fn format_peer_request_projection(
239 from_peer_id: PeerId,
240 display_name: Option<&str>,
241 request_id: impl std::fmt::Display,
242 intent: &str,
243 params: &Value,
244) -> String {
245 let params_str = if params.is_null() || matches!(params, Value::Object(map) if map.is_empty()) {
246 String::new()
247 } else {
248 format!(
249 "\nParams: {}",
250 serde_json::to_string_pretty(params).unwrap_or_default()
251 )
252 };
253 let request_id = request_id.to_string();
254 let display_suffix = display_name
255 .map(str::trim)
256 .filter(|name| !name.is_empty())
257 .map(|name| format!(" (display_name: {name})"))
258 .unwrap_or_default();
259 let response_call =
260 SendResponseCallProjection::new(from_peer_id, display_name, request_id.clone());
261
262 format!(
263 "Peer request from peer_id {from_peer_id}{display_suffix} (id: {request_id})\n\
264 Intent: {intent}{params_str}\n\
265 Request ID: {request_id}\n\
266 \n\
267 This is a correlated peer request. {} \
268 Do not answer this request with send_message.",
269 response_call.instruction_text()
270 )
271}
272
273pub fn format_peer_response_projection(
275 from_peer: &str,
276 in_reply_to: impl std::fmt::Display,
277 status: ResponseStatus,
278 result: &Value,
279) -> String {
280 let status_str = match status {
281 ResponseStatus::Accepted => "accepted",
282 ResponseStatus::Completed => "completed",
283 ResponseStatus::Failed => "failed",
284 };
285 let result_str = if result.is_null() || matches!(result, Value::Object(map) if map.is_empty()) {
286 String::new()
287 } else {
288 format!(
289 "\nResult: {}",
290 serde_json::to_string_pretty(result).unwrap_or_default()
291 )
292 };
293
294 format!(
295 "Peer response from {from_peer} (to request: {in_reply_to})\n\
296 Status: {status_str}{result_str}"
297 )
298}
299
300pub fn format_peer_ack_projection(from_peer: &str, in_reply_to: impl std::fmt::Display) -> String {
302 format!("Peer ack from {from_peer} (to request: {in_reply_to})")
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310pub enum PeerInputClass {
311 ActionableMessage,
313 ActionableRequest,
315 ResponseProgress,
317 ResponseTerminal,
319 PeerLifecycleAdded,
321 PeerLifecycleRetired,
323 PeerLifecycleUnwired,
325 PeerLifecycleKickoffFailed,
327 PeerLifecycleKickoffCancelled,
329 SilentRequest,
331 Ack,
333 PlainEvent,
335}
336
337const fn peer_input_class_actionable_grouping(class: PeerInputClass) -> bool {
347 matches!(
348 class,
349 PeerInputClass::ActionableMessage
350 | PeerInputClass::ActionableRequest
351 | PeerInputClass::ResponseProgress
352 | PeerInputClass::ResponseTerminal
353 | PeerInputClass::PlainEvent
354 | PeerInputClass::PeerLifecycleKickoffFailed
355 | PeerInputClass::PeerLifecycleKickoffCancelled
356 )
357}
358
359#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
361pub enum PeerIngressAuthExemption {
362 SupervisorBridge,
364}
365
366impl PeerIngressAuthExemption {
367 pub const fn intent(self) -> &'static str {
368 match self {
369 Self::SupervisorBridge => SUPERVISOR_BRIDGE_INTENT,
370 }
371 }
372
373 pub fn matches_intent(self, intent: &str) -> bool {
374 self.intent() == intent
375 }
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
380pub enum PeerIngressAuthDecision {
381 Required,
383 Exempt(PeerIngressAuthExemption),
385}
386
387impl PeerIngressAuthDecision {
388 pub const fn is_exempt(self) -> bool {
389 matches!(self, Self::Exempt(_))
390 }
391}
392
393#[derive(Debug, Clone, PartialEq, Eq)]
399pub enum PeerIngressConvention {
400 Message,
401 Request {
402 request_id: String,
403 intent: String,
404 },
405 Response {
406 in_reply_to: InteractionId,
407 status: ResponseStatus,
408 },
409 Ack {
410 in_reply_to: InteractionId,
411 },
412 Lifecycle {
413 kind: PeerLifecycleKind,
414 peer: String,
415 },
416 PlainEvent {
417 source_name: String,
418 },
419}
420
421#[derive(Debug, Clone, PartialEq, Eq)]
427pub struct PeerIngressFact {
428 pub interaction_id: InteractionId,
430 pub class: PeerInputClass,
432 pub kind: PeerIngressKind,
434 pub canonical_peer_id: Option<PeerId>,
436 pub display_name: Option<PeerName>,
438 pub signing_pubkey: Option<[u8; 32]>,
440 pub route: Option<PeerRoute>,
442 pub auth: Option<PeerIngressAuthDecision>,
444 pub convention: PeerIngressConvention,
446}
447
448#[derive(Debug, Clone, PartialEq, Eq)]
450pub struct PeerIngressIdentity {
451 pub canonical_peer_id: PeerId,
452 pub display_label: String,
453 pub signing_pubkey: Option<[u8; 32]>,
454 pub convention: PeerIngressConvention,
455}
456
457impl PeerIngressIdentity {
458 pub fn new(
459 canonical_peer_id: PeerId,
460 display_label: impl Into<String>,
461 convention: PeerIngressConvention,
462 ) -> Self {
463 Self {
464 canonical_peer_id,
465 display_label: display_label.into(),
466 signing_pubkey: None,
467 convention,
468 }
469 }
470
471 pub fn with_signing_pubkey(mut self, signing_pubkey: [u8; 32]) -> Self {
472 self.signing_pubkey = Some(signing_pubkey);
473 self
474 }
475}
476
477impl PeerIngressFact {
478 pub fn peer(
479 interaction_id: InteractionId,
480 class: PeerInputClass,
481 kind: PeerIngressKind,
482 auth: Option<PeerIngressAuthDecision>,
483 identity: PeerIngressIdentity,
484 ) -> Self {
485 let PeerIngressIdentity {
486 canonical_peer_id,
487 display_label,
488 signing_pubkey,
489 convention,
490 } = identity;
491 let display_name = PeerName::new(display_label).ok();
492 let route = Some(match &display_name {
493 Some(name) => PeerRoute::with_display_name(canonical_peer_id, name.clone()),
494 None => PeerRoute::new(canonical_peer_id),
495 });
496 Self {
497 interaction_id,
498 class,
499 kind,
500 canonical_peer_id: Some(canonical_peer_id),
501 display_name,
502 signing_pubkey,
503 route,
504 auth,
505 convention,
506 }
507 }
508
509 pub fn plain_event(
510 interaction_id: InteractionId,
511 source_name: impl Into<String>,
512 class: PeerInputClass,
513 kind: PeerIngressKind,
514 ) -> Self {
515 let source_name = source_name.into();
516 Self {
517 interaction_id,
518 class,
519 kind,
520 canonical_peer_id: None,
521 display_name: None,
522 signing_pubkey: None,
523 route: None,
524 auth: None,
525 convention: PeerIngressConvention::PlainEvent { source_name },
526 }
527 }
528
529 pub fn canonical_peer_id_string(&self) -> Option<String> {
530 self.canonical_peer_id.map(|peer_id| peer_id.as_str())
531 }
532
533 pub fn display_label(&self) -> Option<String> {
534 self.display_name.as_ref().map(PeerName::as_string)
535 }
536
537 pub fn diagnostic_label(&self) -> String {
538 self.display_label()
539 .or_else(|| self.canonical_peer_id_string())
540 .unwrap_or_else(|| "<unknown-peer-ingress>".to_string())
541 }
542
543 pub fn plain_event_source_name(&self) -> Option<&str> {
544 match &self.convention {
545 PeerIngressConvention::PlainEvent { source_name } => Some(source_name.as_str()),
546 _ => None,
547 }
548 }
549}
550
551#[derive(Debug, Clone, PartialEq, Eq)]
553pub struct PeerIngressClassification {
554 pub class: PeerInputClass,
555 pub actionable: bool,
561 pub kind: PeerIngressKind,
562 pub auth: PeerIngressAuthDecision,
563 pub lifecycle_kind: Option<PeerLifecycleKind>,
564 pub response_terminality: Option<TerminalityClass>,
565}
566
567impl PeerIngressClassification {
568 pub const fn required(class: PeerInputClass, kind: PeerIngressKind) -> Self {
569 Self {
570 class,
571 actionable: peer_input_class_actionable_grouping(class),
572 kind,
573 auth: PeerIngressAuthDecision::Required,
574 lifecycle_kind: None,
575 response_terminality: None,
576 }
577 }
578}
579
580#[derive(Debug, Clone, PartialEq)]
586pub struct PeerIngressEnvelopeFacts {
587 pub item_id: String,
588 pub from_peer: String,
589 pub from_peer_id: PeerId,
590 pub kind: PeerIngressEnvelopeKind,
591}
592
593#[derive(Debug, Clone, PartialEq)]
594pub enum PeerIngressEnvelopeKind {
595 Message {
596 body: String,
597 },
598 Request {
599 intent: String,
600 params: Value,
601 },
602 Lifecycle {
603 kind: PeerLifecycleKind,
604 params: Value,
605 },
606 Response {
607 in_reply_to: String,
608 status: ResponseStatus,
609 result: Value,
610 },
611 Ack {
612 in_reply_to: String,
613 },
614}
615
616#[derive(Debug, Clone, PartialEq, Eq)]
618pub struct PeerIngressPlainEventFacts {
619 pub source_name: String,
620 pub body: String,
621}
622
623#[derive(Debug, Clone, PartialEq, Eq)]
625pub struct PeerIngressAdmission {
626 pub classification: PeerIngressClassification,
627 pub from_peer_id: Option<PeerId>,
633 pub lifecycle_peer: Option<String>,
634 pub request_id: Option<String>,
635 pub rendered_text: String,
636}
637
638#[derive(Debug, Clone, Copy, PartialEq, Eq)]
644pub struct PeerIngressReceiveFacts {
645 pub kind: PeerIngressKind,
646 pub current_phase: PeerIngressAuthorityPhase,
647 pub auth_required: bool,
648 pub auth_exempt: bool,
649 pub trusted: bool,
650 pub queued_work_present: bool,
651 pub queue_closed: bool,
652 pub queue_capacity_available: bool,
653}
654
655#[derive(Debug, Clone, Copy, PartialEq, Eq)]
657pub struct PeerIngressReceiveAuthority {
658 pub outcome: PeerIngressReceiveOutcome,
659 pub admission_diagnostic: Option<PeerIngressAdmissionDiagnostic>,
660 pub authority_phase: PeerIngressAuthorityPhase,
661}
662
663#[derive(Debug, Clone, Copy, PartialEq, Eq)]
665pub enum PeerIngressReceiveOutcome {
666 Admitted,
667 DroppedUntrustedSender,
668 DroppedSessionClosed,
669 DroppedInboxFull,
670}
671
672#[derive(Debug, Clone, Copy, PartialEq, Eq)]
677pub struct PeerIngressDequeueFacts {
678 pub kind: PeerIngressKind,
679 pub auth: PeerIngressAuthDecision,
680 pub queued_work_remaining: bool,
681}
682
683#[derive(Debug, Clone, Copy, PartialEq, Eq)]
685pub struct PeerIngressDequeueAuthority {
686 pub authority_phase: PeerIngressAuthorityPhase,
687}
688
689pub fn render_peer_ingress_admitted_text(
695 facts: &PeerIngressEnvelopeFacts,
696 classification: &PeerIngressClassification,
697) -> String {
698 match &facts.kind {
699 PeerIngressEnvelopeKind::Message { body } => {
700 format_peer_message_projection(&facts.from_peer, body)
701 }
702 PeerIngressEnvelopeKind::Request { intent, params } => {
703 if classification.lifecycle_kind.is_some() {
704 String::new()
705 } else {
706 format_peer_request_projection(
707 facts.from_peer_id,
708 Some(&facts.from_peer),
709 facts.item_id.as_str(),
710 intent,
711 params,
712 )
713 }
714 }
715 PeerIngressEnvelopeKind::Lifecycle { .. } => String::new(),
716 PeerIngressEnvelopeKind::Response {
717 in_reply_to,
718 status,
719 result,
720 } => format_peer_response_projection(&facts.from_peer, in_reply_to, *status, result),
721 PeerIngressEnvelopeKind::Ack { in_reply_to } => {
722 format_peer_ack_projection(&facts.from_peer, in_reply_to)
723 }
724 }
725}
726
727#[derive(Debug, Clone)]
733pub struct PeerInputCandidate {
734 pub interaction: InboxInteraction,
736 pub ingress: PeerIngressFact,
739 pub lifecycle_peer: Option<String>,
741 pub response_terminality: Option<TerminalityClass>,
743}
744
745impl PeerInputCandidate {
746 pub fn new(
747 interaction: InboxInteraction,
748 ingress: PeerIngressFact,
749 lifecycle_peer: Option<String>,
750 ) -> Self {
751 Self {
752 interaction,
753 ingress,
754 lifecycle_peer,
755 response_terminality: None,
756 }
757 }
758
759 pub fn class(&self) -> PeerInputClass {
760 self.ingress.class
761 }
762
763 pub fn kind(&self) -> PeerIngressKind {
764 self.ingress.kind
765 }
766
767 pub fn auth(&self) -> Option<PeerIngressAuthDecision> {
768 self.ingress.auth
769 }
770
771 pub fn from_peer_id(&self) -> Option<PeerId> {
778 self.ingress.canonical_peer_id
779 }
780}
781
782pub type ClassifiedInboxInteraction = PeerInputCandidate;
784
785#[derive(Debug, Clone, Copy, PartialEq, Eq)]
790pub enum PeerIngressKind {
791 Message,
792 Request,
793 Response,
794 Ack,
795 PlainEvent,
796}
797
798#[derive(Debug, Clone, PartialEq, Eq)]
805pub struct PeerIngressDiagnosticDisplay(String);
806
807impl PeerIngressDiagnosticDisplay {
808 pub fn new(value: impl Into<String>) -> Self {
809 Self(value.into())
810 }
811
812 pub fn as_str(&self) -> &str {
813 &self.0
814 }
815}
816
817impl std::fmt::Display for PeerIngressDiagnosticDisplay {
818 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
819 self.0.fmt(f)
820 }
821}
822
823#[derive(Debug, Clone, Copy, PartialEq, Eq)]
829pub enum PeerIngressAdmissionDiagnostic {
830 TrustedAtAdmission,
831 UntrustedAtAdmission,
832}
833
834impl PeerIngressAdmissionDiagnostic {
835 pub const fn from_trusted(trusted: bool) -> Self {
836 if trusted {
837 Self::TrustedAtAdmission
838 } else {
839 Self::UntrustedAtAdmission
840 }
841 }
842
843 pub const fn trusted_at_admission(self) -> bool {
844 matches!(self, Self::TrustedAtAdmission)
845 }
846}
847
848#[derive(Debug, Clone, PartialEq, Eq)]
855pub struct PeerIngressEntrySnapshot {
856 pub raw_item_id: InteractionId,
858 pub interaction_id: Option<InteractionId>,
860 pub class: PeerInputClass,
862 pub actionable: bool,
866 pub kind: PeerIngressKind,
868 pub from_peer_display: Option<PeerIngressDiagnosticDisplay>,
870 pub canonical_peer_id: Option<PeerId>,
872 pub display_name: Option<PeerName>,
874 pub signing_pubkey: Option<[u8; 32]>,
876 pub route: Option<PeerRoute>,
878 pub lifecycle_peer_display: Option<PeerIngressDiagnosticDisplay>,
880 pub request_correlation_id: Option<InteractionId>,
882 pub auth: Option<PeerIngressAuthDecision>,
885 pub admission_diagnostic: Option<PeerIngressAdmissionDiagnostic>,
888 pub response_terminality: Option<TerminalityClass>,
891}
892
893#[derive(Debug, Clone, PartialEq, Eq, Default)]
899pub struct PeerIngressQueueSnapshot {
900 pub total_count: usize,
901 pub actionable_count: usize,
902 pub response_count: usize,
903 pub lifecycle_count: usize,
904 pub silent_request_count: usize,
905 pub ack_count: usize,
906 pub plain_event_count: usize,
907 pub queued_entries: Vec<PeerIngressEntrySnapshot>,
908}
909
910#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
915pub enum PeerIngressAuthorityPhase {
916 #[default]
917 Absent,
918 Received,
919 Dropped,
920 Delivered,
921}
922
923#[derive(Debug, Clone, PartialEq, Eq)]
928pub struct PeerIngressRuntimeSnapshot {
929 pub self_peer_id: crate::comms::PeerId,
931 pub auth_required: bool,
933 pub authority_phase: PeerIngressAuthorityPhase,
935 pub trusted_peers: Vec<TrustedPeerDescriptor>,
937 pub submission_queue_len: usize,
939 pub queue: PeerIngressQueueSnapshot,
941}
942
943#[cfg(test)]
944#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
945mod tests {
946 use super::*;
947
948 #[test]
949 fn interaction_id_json_roundtrip() {
950 let id = InteractionId(Uuid::new_v4());
951 let json = serde_json::to_string(&id).unwrap();
952 let parsed: InteractionId = serde_json::from_str(&json).unwrap();
953 assert_eq!(id, parsed);
954 }
955
956 #[test]
957 fn interaction_content_message_json_roundtrip() {
958 let content = InteractionContent::Message {
959 body: "hello".to_string(),
960 blocks: None,
961 };
962 let json = serde_json::to_value(&content).unwrap();
963 assert_eq!(json["type"], "message");
964 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
965 assert_eq!(content, parsed);
966 }
967
968 #[test]
969 fn interaction_content_request_json_roundtrip() {
970 let content = InteractionContent::Request {
971 intent: "review".to_string(),
972 params: serde_json::json!({"pr": 42}),
973 blocks: None,
974 };
975 let json = serde_json::to_value(&content).unwrap();
976 assert_eq!(json["type"], "request");
977 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
978 assert_eq!(content, parsed);
979 }
980
981 #[test]
982 fn interaction_content_response_json_roundtrip() {
983 let id = InteractionId(Uuid::new_v4());
984 let content = InteractionContent::Response {
985 in_reply_to: id,
986 status: ResponseStatus::Completed,
987 result: serde_json::json!({"ok": true}),
988 blocks: None,
989 };
990 let json = serde_json::to_value(&content).unwrap();
991 assert_eq!(json["type"], "response");
992 assert_eq!(json["status"], "completed");
993 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
994 assert_eq!(content, parsed);
995 }
996
997 #[test]
998 fn response_status_json_roundtrip_all_variants() {
999 for (variant, expected_str) in [
1000 (ResponseStatus::Accepted, "accepted"),
1001 (ResponseStatus::Completed, "completed"),
1002 (ResponseStatus::Failed, "failed"),
1003 ] {
1004 let json = serde_json::to_value(variant).unwrap();
1005 assert_eq!(json, expected_str);
1006 let parsed: ResponseStatus = serde_json::from_value(json).unwrap();
1007 assert_eq!(variant, parsed);
1008 }
1009 }
1010
1011 #[test]
1012 fn interaction_message_with_blocks_roundtrip() {
1013 let content = InteractionContent::Message {
1014 body: "hello".to_string(),
1015 blocks: Some(vec![
1016 ContentBlock::Text {
1017 text: "hello".to_string(),
1018 },
1019 ContentBlock::Image {
1020 media_type: "image/png".to_string(),
1021 data: "iVBORw0KGgo=".into(),
1022 },
1023 ]),
1024 };
1025 let json = serde_json::to_value(&content).unwrap();
1026 assert_eq!(json["type"], "message");
1027 assert!(json["blocks"].is_array());
1028 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
1029 assert_eq!(content, parsed);
1030 }
1031
1032 #[test]
1033 fn inbox_interaction_preserves_runtime_hints() {
1034 let interaction = InboxInteraction {
1035 objective_id: None,
1036 id: InteractionId(Uuid::new_v4()),
1037 from_route: None,
1038 from: "event:webhook".into(),
1039 content: InteractionContent::Message {
1040 body: "hello".into(),
1041 blocks: None,
1042 },
1043 rendered_text: "External event via webhook: hello".into(),
1044 handling_mode: HandlingMode::Steer,
1045 render_metadata: Some(RenderMetadata {
1046 class: crate::types::RenderClass::SystemNotice,
1047 salience: crate::types::RenderSalience::Urgent,
1048 }),
1049 sender_taint: None,
1050 };
1051
1052 assert_eq!(interaction.handling_mode, HandlingMode::Steer);
1053 assert!(interaction.render_metadata.is_some());
1054 }
1055
1056 #[test]
1057 fn interaction_message_without_blocks_compat() {
1058 let old_json = r#"{"type":"message","body":"hello"}"#;
1060 let parsed: InteractionContent = serde_json::from_str(old_json).unwrap();
1061 match parsed {
1062 InteractionContent::Message { body, blocks } => {
1063 assert_eq!(body, "hello");
1064 assert_eq!(blocks, None);
1065 }
1066 other => panic!("Expected Message, got {other:?}"),
1067 }
1068
1069 let content = InteractionContent::Message {
1071 body: "test".to_string(),
1072 blocks: None,
1073 };
1074 let json = serde_json::to_string(&content).unwrap();
1075 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1076 assert!(
1077 value.get("blocks").is_none(),
1078 "blocks: None should not appear in JSON"
1079 );
1080 }
1081
1082 #[test]
1089 fn actionable_grouping_mirror_matches_machine_grouping_for_all_variants() {
1090 for (class, expected_actionable) in [
1093 (PeerInputClass::ActionableMessage, true),
1094 (PeerInputClass::ActionableRequest, true),
1095 (PeerInputClass::ResponseProgress, true),
1096 (PeerInputClass::ResponseTerminal, true),
1097 (PeerInputClass::PlainEvent, true),
1098 (PeerInputClass::PeerLifecycleKickoffFailed, true),
1099 (PeerInputClass::PeerLifecycleKickoffCancelled, true),
1100 (PeerInputClass::PeerLifecycleAdded, false),
1101 (PeerInputClass::PeerLifecycleRetired, false),
1102 (PeerInputClass::PeerLifecycleUnwired, false),
1103 (PeerInputClass::SilentRequest, false),
1104 (PeerInputClass::Ack, false),
1105 ] {
1106 assert_eq!(
1107 peer_input_class_actionable_grouping(class),
1108 expected_actionable,
1109 "actionable grouping verdict drifted for {class:?}"
1110 );
1111 }
1112 fn assert_variant_covered(class: PeerInputClass) {
1116 match class {
1117 PeerInputClass::ActionableMessage
1118 | PeerInputClass::ActionableRequest
1119 | PeerInputClass::ResponseProgress
1120 | PeerInputClass::ResponseTerminal
1121 | PeerInputClass::PlainEvent
1122 | PeerInputClass::PeerLifecycleKickoffFailed
1123 | PeerInputClass::PeerLifecycleKickoffCancelled
1124 | PeerInputClass::PeerLifecycleAdded
1125 | PeerInputClass::PeerLifecycleRetired
1126 | PeerInputClass::PeerLifecycleUnwired
1127 | PeerInputClass::SilentRequest
1128 | PeerInputClass::Ack => (),
1129 }
1130 }
1131 assert_variant_covered(PeerInputClass::Ack);
1132 }
1133}