1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use uuid::Uuid;
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct IpcMessage {
11 pub topic: String,
13 pub payload: IpcPayload,
15 #[serde(default)]
17 pub signature: Option<Vec<u8>>,
18 pub source_id: Uuid,
20 #[cfg_attr(feature = "clock", serde(default = "Utc::now"))]
29 #[cfg_attr(not(feature = "clock"), serde(default = "default_unix_epoch"))]
30 pub timestamp: DateTime<Utc>,
31 #[serde(default)]
34 pub seq: u64,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub principal: Option<String>,
42}
43
44#[cfg(not(feature = "clock"))]
49fn default_unix_epoch() -> DateTime<Utc> {
50 DateTime::<Utc>::from_timestamp(0, 0).unwrap_or(DateTime::<Utc>::MIN_UTC)
54}
55
56impl IpcMessage {
57 #[cfg(feature = "clock")]
62 #[must_use]
63 pub fn new(topic: impl Into<String>, payload: IpcPayload, source_id: Uuid) -> Self {
64 Self {
65 topic: topic.into(),
66 payload,
67 signature: None,
68 source_id,
69 timestamp: Utc::now(),
70 seq: 0,
71 principal: None,
72 }
73 }
74
75 #[must_use]
77 pub fn with_signature(mut self, signature: Vec<u8>) -> Self {
78 self.signature = Some(signature);
79 self
80 }
81
82 #[must_use]
84 pub fn with_principal(mut self, principal: impl Into<String>) -> Self {
85 self.principal = Some(principal.into());
86 self
87 }
88}
89
90fn default_session_id() -> String {
92 "default".into()
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
97#[serde(tag = "type", rename_all = "snake_case")]
98pub enum IpcPayload {
99 RawJson(Value),
101 UserInput {
103 text: String,
105 #[serde(default = "default_session_id")]
107 session_id: String,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 context: Option<Value>,
111 },
112 AgentResponse {
114 text: String,
116 is_final: bool,
118 #[serde(default = "default_session_id")]
120 session_id: String,
121 },
122 ApprovalRequired {
124 request_id: String,
126 action: String,
128 resource: String,
130 reason: String,
132 },
133 ApprovalResponse {
135 request_id: String,
137 decision: String,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 reason: Option<String>,
142 },
143 OnboardingRequired {
145 capsule_id: String,
147 fields: Vec<OnboardingField>,
149 },
150 LlmRequest {
152 request_id: Uuid,
154 model: String,
156 messages: Vec<crate::llm::Message>,
158 tools: Vec<crate::llm::LlmToolDefinition>,
160 system: String,
162 },
163 LlmStreamEvent {
165 request_id: Uuid,
167 event: crate::llm::StreamEvent,
169 },
170 LlmResponse {
172 request_id: Uuid,
174 response: crate::llm::LlmResponse,
176 },
177 ToolExecuteRequest {
179 call_id: String,
181 tool_name: String,
183 arguments: Value,
185 },
186 ToolExecuteResult {
188 call_id: String,
190 result: crate::llm::ToolCallResult,
192 },
193 ToolCancelRequest {
195 call_ids: Vec<String>,
197 },
198 SelectionRequired {
200 request_id: String,
202 title: String,
204 options: Vec<SelectionOption>,
206 callback_topic: String,
208 },
209 ElicitRequest {
211 request_id: Uuid,
213 capsule_id: String,
215 field: OnboardingField,
217 },
218 ElicitResponse {
220 request_id: Uuid,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 value: Option<String>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 values: Option<Vec<String>>,
228 },
229 Connect,
231 Disconnect {
233 #[serde(default, skip_serializing_if = "Option::is_none")]
235 reason: Option<String>,
236 },
237 Custom {
239 data: Value,
241 },
242 #[serde(other)]
244 Unknown,
245}
246
247impl IpcPayload {
248 #[must_use]
250 pub fn is_known_tag(tag: &str) -> bool {
251 matches!(
252 tag,
253 "raw_json"
254 | "user_input"
255 | "agent_response"
256 | "approval_required"
257 | "approval_response"
258 | "onboarding_required"
259 | "llm_request"
260 | "llm_stream_event"
261 | "llm_response"
262 | "tool_execute_request"
263 | "tool_execute_result"
264 | "tool_cancel_request"
265 | "selection_required"
266 | "elicit_request"
267 | "elicit_response"
268 | "connect"
269 | "disconnect"
270 | "custom"
271 )
272 }
273
274 pub fn from_json_value(data: Value) -> Self {
277 let is_known = data
278 .get("type")
279 .and_then(|v| v.as_str())
280 .is_some_and(Self::is_known_tag);
281
282 if is_known {
283 serde_json::from_value::<Self>(data.clone()).unwrap_or(Self::Custom { data })
284 } else {
285 Self::Custom { data }
286 }
287 }
288
289 pub fn to_guest_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
299 match self {
300 Self::Custom { data } | Self::RawJson(data) => serde_json::to_vec(data),
301 other => serde_json::to_vec(other),
302 }
303 }
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
308pub struct SelectionOption {
309 pub id: String,
311 pub label: String,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub description: Option<String>,
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
320pub struct OnboardingField {
321 pub key: String,
323 pub prompt: String,
325 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub description: Option<String>,
328 pub field_type: OnboardingFieldType,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub default: Option<String>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub placeholder: Option<String>,
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
340pub enum OnboardingFieldType {
341 Text,
343 Secret,
345 Enum(Vec<String>),
347 Array,
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 #[test]
356 fn ipc_message_signature() {
357 let msg = IpcMessage::new(
358 "test.topic",
359 IpcPayload::AgentResponse {
360 text: "hello".into(),
361 is_final: true,
362 session_id: "default".into(),
363 },
364 Uuid::new_v4(),
365 );
366 assert!(msg.signature.is_none());
367
368 let signed = msg.with_signature(vec![1, 2, 3]);
369 assert_eq!(signed.signature, Some(vec![1, 2, 3]));
370 }
371
372 #[test]
373 fn ipc_message_principal() {
374 let msg = IpcMessage::new(
375 "test.topic",
376 IpcPayload::Custom {
377 data: serde_json::json!({}),
378 },
379 Uuid::new_v4(),
380 );
381 assert!(msg.principal.is_none());
382
383 let with_principal = msg.with_principal("alice");
384 assert_eq!(with_principal.principal.as_deref(), Some("alice"));
385 }
386
387 #[test]
388 fn ipc_message_principal_serde_roundtrip() {
389 let msg = IpcMessage::new(
390 "test.topic",
391 IpcPayload::Custom {
392 data: serde_json::json!({}),
393 },
394 Uuid::nil(),
395 )
396 .with_principal("bob");
397 let json = serde_json::to_string(&msg).unwrap();
398 assert!(json.contains(r#""principal":"bob""#));
399
400 let parsed: IpcMessage = serde_json::from_str(&json).unwrap();
401 assert_eq!(parsed.principal.as_deref(), Some("bob"));
402 }
403
404 #[test]
405 fn ipc_message_principal_absent_in_json() {
406 let json = r#"{"topic":"t","payload":{"type":"connect"},"source_id":"00000000-0000-0000-0000-000000000000","timestamp":"2024-01-01T00:00:00Z","seq":0}"#;
408 let msg: IpcMessage = serde_json::from_str(json).unwrap();
409 assert!(msg.principal.is_none());
410 }
411
412 #[test]
413 fn ipc_message_principal_not_serialized_when_none() {
414 let msg = IpcMessage::new("test.topic", IpcPayload::Connect, Uuid::nil());
415 let json = serde_json::to_string(&msg).unwrap();
416 assert!(!json.contains("principal"));
417 }
418
419 #[test]
420 fn unknown_type_tag_deserializes_to_unknown() {
421 let json = r#"{"type":"future_variant","some_data":42}"#;
422 let payload: IpcPayload = serde_json::from_str(json).unwrap();
423 assert_eq!(payload, IpcPayload::Unknown);
424 }
425
426 #[test]
427 fn ipc_message_parses_cli_proxy_wire_format() {
428 let wire = r#"{"topic":"agent.v1.response","payload":{"type":"agent_response","text":"hi","is_final":true,"session_id":"00000000-0000-0000-0000-000000000000"},"source_id":"00000000-0000-0000-0000-000000000000"}"#;
436 let msg: IpcMessage = serde_json::from_str(wire).expect("cli proxy frame must parse");
437 assert_eq!(msg.topic, "agent.v1.response");
438 assert!(msg.signature.is_none());
439 assert_eq!(msg.seq, 0);
440 match msg.payload {
441 IpcPayload::AgentResponse { text, is_final, .. } => {
442 assert_eq!(text, "hi");
443 assert!(is_final);
444 },
445 other => panic!("unexpected payload variant: {other:?}"),
446 }
447 }
448
449 #[test]
450 fn known_variants_unaffected_by_unknown() {
451 let payload = IpcPayload::AgentResponse {
452 text: "hello".into(),
453 is_final: true,
454 session_id: "s1".into(),
455 };
456 let json = serde_json::to_string(&payload).unwrap();
457 let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
458 assert_eq!(parsed, payload);
459 }
460
461 #[test]
462 fn unknown_variant_serializes_as_type_unknown() {
463 let json = serde_json::to_string(&IpcPayload::Unknown).unwrap();
464 assert_eq!(json, r#"{"type":"unknown"}"#);
465 }
466
467 #[test]
471 #[allow(clippy::too_many_lines, reason = "exhaustive variant table")]
472 fn is_known_tag_covers_all_variants() {
473 const EXPECTED_VARIANT_COUNT: usize = 17;
474
475 let representatives: Vec<IpcPayload> = vec![
476 IpcPayload::RawJson(serde_json::json!({"key": "val"})),
477 IpcPayload::UserInput {
478 text: String::new(),
479 session_id: "s".into(),
480 context: None,
481 },
482 IpcPayload::AgentResponse {
483 text: String::new(),
484 is_final: false,
485 session_id: "s".into(),
486 },
487 IpcPayload::ApprovalRequired {
488 request_id: "req-1".into(),
489 action: String::new(),
490 resource: String::new(),
491 reason: String::new(),
492 },
493 IpcPayload::ApprovalResponse {
494 request_id: "req-1".into(),
495 decision: "approve".into(),
496 reason: None,
497 },
498 IpcPayload::OnboardingRequired {
499 capsule_id: String::new(),
500 fields: vec![],
501 },
502 IpcPayload::LlmRequest {
503 request_id: Uuid::nil(),
504 model: String::new(),
505 messages: vec![],
506 tools: vec![],
507 system: String::new(),
508 },
509 IpcPayload::LlmStreamEvent {
510 request_id: Uuid::nil(),
511 event: crate::llm::StreamEvent::TextDelta(String::new()),
512 },
513 IpcPayload::LlmResponse {
514 request_id: Uuid::nil(),
515 response: crate::llm::LlmResponse {
516 message: crate::llm::Message {
517 role: crate::llm::MessageRole::Assistant,
518 content: crate::llm::MessageContent::Text(String::new()),
519 },
520 has_tool_calls: false,
521 stop_reason: crate::llm::StopReason::EndTurn,
522 usage: crate::llm::Usage {
523 input_tokens: 0,
524 output_tokens: 0,
525 },
526 },
527 },
528 IpcPayload::ToolExecuteRequest {
529 call_id: String::new(),
530 tool_name: String::new(),
531 arguments: Value::Null,
532 },
533 IpcPayload::ToolExecuteResult {
534 call_id: String::new(),
535 result: crate::llm::ToolCallResult {
536 call_id: String::new(),
537 content: String::new(),
538 is_error: false,
539 },
540 },
541 IpcPayload::SelectionRequired {
542 request_id: String::new(),
543 title: String::new(),
544 options: vec![],
545 callback_topic: String::new(),
546 },
547 IpcPayload::ElicitRequest {
548 request_id: Uuid::nil(),
549 capsule_id: String::new(),
550 field: OnboardingField {
551 key: String::new(),
552 prompt: String::new(),
553 description: None,
554 field_type: OnboardingFieldType::Text,
555 default: None,
556 placeholder: None,
557 },
558 },
559 IpcPayload::ElicitResponse {
560 request_id: Uuid::nil(),
561 value: None,
562 values: None,
563 },
564 IpcPayload::Connect,
565 IpcPayload::Disconnect { reason: None },
566 IpcPayload::Custom {
567 data: Value::Object(serde_json::Map::new()),
568 },
569 ];
570
571 assert_eq!(
572 representatives.len(),
573 EXPECTED_VARIANT_COUNT,
574 "IpcPayload variant count changed. Update the representatives list \
575 and bump EXPECTED_VARIANT_COUNT."
576 );
577
578 for variant in &representatives {
579 let json = serde_json::to_value(variant).unwrap();
580 let tag = json["type"]
581 .as_str()
582 .unwrap_or_else(|| panic!("variant {variant:?} has no `type` tag"));
583 assert!(
584 IpcPayload::is_known_tag(tag),
585 "is_known_tag does not recognise tag '{tag}' from variant {variant:?}"
586 );
587 }
588 }
589
590 #[test]
591 fn is_known_tag_rejects_unknown_tags() {
592 assert!(!IpcPayload::is_known_tag("my_plugin_msg"));
593 assert!(!IpcPayload::is_known_tag("unknown"));
594 assert!(!IpcPayload::is_known_tag(""));
595 assert!(!IpcPayload::is_known_tag("Raw_Json"));
596 }
597
598 #[test]
599 fn onboarding_field_roundtrip() {
600 let field = OnboardingField {
601 key: "apiKey".into(),
602 prompt: "Enter API key".into(),
603 description: None,
604 field_type: OnboardingFieldType::Secret,
605 default: None,
606 placeholder: None,
607 };
608 let json = serde_json::to_string(&field).unwrap();
609 let parsed: OnboardingField = serde_json::from_str(&json).unwrap();
610 assert_eq!(parsed, field);
611 }
612
613 #[test]
614 fn onboarding_field_roundtrip_array() {
615 let field = OnboardingField {
616 key: "relays".into(),
617 prompt: "Enter relay URLs".into(),
618 description: Some("Nostr relay endpoints".into()),
619 field_type: OnboardingFieldType::Array,
620 default: None,
621 placeholder: None,
622 };
623 let json = serde_json::to_string(&field).unwrap();
624 let parsed: OnboardingField = serde_json::from_str(&json).unwrap();
625 assert_eq!(parsed, field);
626 }
627
628 #[test]
629 fn onboarding_required_payload_roundtrip() {
630 let payload = IpcPayload::OnboardingRequired {
631 capsule_id: "test-capsule".into(),
632 fields: vec![
633 OnboardingField {
634 key: "network".into(),
635 prompt: "Select network".into(),
636 description: Some("Choose the target network".into()),
637 field_type: OnboardingFieldType::Enum(vec!["testnet".into(), "mainnet".into()]),
638 default: Some("testnet".into()),
639 placeholder: None,
640 },
641 OnboardingField {
642 key: "apiKey".into(),
643 prompt: "Enter API key".into(),
644 description: None,
645 field_type: OnboardingFieldType::Secret,
646 default: None,
647 placeholder: None,
648 },
649 ],
650 };
651 let json = serde_json::to_string(&payload).unwrap();
652 let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
653 assert_eq!(parsed, payload);
654 }
655
656 #[test]
657 fn elicit_request_roundtrip() {
658 let payload = IpcPayload::ElicitRequest {
659 request_id: Uuid::nil(),
660 capsule_id: "my-capsule".into(),
661 field: OnboardingField {
662 key: "api_url".into(),
663 prompt: "Enter API URL".into(),
664 description: Some("The backend endpoint".into()),
665 field_type: OnboardingFieldType::Text,
666 default: Some("https://example.com".into()),
667 placeholder: None,
668 },
669 };
670 let json = serde_json::to_string(&payload).unwrap();
671 let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
672 assert_eq!(parsed, payload);
673 }
674
675 #[test]
676 fn elicit_response_roundtrip() {
677 let payload = IpcPayload::ElicitResponse {
678 request_id: Uuid::nil(),
679 value: Some("hello".into()),
680 values: None,
681 };
682 let json = serde_json::to_string(&payload).unwrap();
683 let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
684 assert_eq!(parsed, payload);
685 }
686
687 #[test]
688 fn disconnect_with_reason_roundtrip() {
689 let payload = IpcPayload::Disconnect {
690 reason: Some("quit".into()),
691 };
692 let json = serde_json::to_string(&payload).unwrap();
693 let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
694 assert_eq!(parsed, payload);
695 assert!(json.contains(r#""type":"disconnect""#), "json: {json}");
696 }
697
698 #[test]
699 fn disconnect_without_reason_roundtrip() {
700 let payload = IpcPayload::Disconnect { reason: None };
701 let json = serde_json::to_string(&payload).unwrap();
702 let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
703 assert_eq!(parsed, payload);
704 assert!(!json.contains("reason"), "json: {json}");
705 }
706
707 #[test]
708 fn to_guest_bytes_custom_returns_inner_data() {
709 let data = serde_json::json!({"session_id": "abc", "messages": []});
710 let payload = IpcPayload::Custom { data: data.clone() };
711 let bytes = payload.to_guest_bytes().unwrap();
712 let roundtrip: Value = serde_json::from_slice(&bytes).unwrap();
713 assert_eq!(roundtrip, data);
714 assert!(roundtrip.get("type").is_none());
715 }
716
717 #[test]
718 fn to_guest_bytes_structured_preserves_type_tag() {
719 let payload = IpcPayload::UserInput {
720 text: "hello".into(),
721 session_id: "default".into(),
722 context: None,
723 };
724 let bytes = payload.to_guest_bytes().unwrap();
725 let roundtrip: Value = serde_json::from_slice(&bytes).unwrap();
726 assert_eq!(
727 roundtrip.get("type").and_then(|v| v.as_str()),
728 Some("user_input")
729 );
730 }
731
732 #[test]
733 fn to_guest_bytes_raw_json_unwraps() {
734 let inner = serde_json::json!({"key": "value"});
735 let payload = IpcPayload::RawJson(inner.clone());
736 let bytes = payload.to_guest_bytes().unwrap();
737 let roundtrip: Value = serde_json::from_slice(&bytes).unwrap();
738 assert_eq!(roundtrip, inner);
739 assert!(roundtrip.get("type").is_none());
740 }
741
742 #[test]
743 fn to_guest_bytes_connect_unit_variant() {
744 let payload = IpcPayload::Connect;
745 let bytes = payload.to_guest_bytes().unwrap();
746 let roundtrip: Value = serde_json::from_slice(&bytes).unwrap();
747 assert_eq!(
748 roundtrip.get("type").and_then(|v| v.as_str()),
749 Some("connect")
750 );
751 }
752
753 #[test]
754 fn from_json_value_unknown_tag_becomes_custom() {
755 let data = serde_json::json!({"type": "my_plugin_msg", "foo": 42});
756 let payload = IpcPayload::from_json_value(data.clone());
757 assert_eq!(payload, IpcPayload::Custom { data });
758 }
759
760 #[test]
761 fn from_json_value_known_tag_parses() {
762 let data = serde_json::json!({
763 "type": "user_input",
764 "text": "hi",
765 "session_id": "s1"
766 });
767 let payload = IpcPayload::from_json_value(data);
768 assert!(matches!(payload, IpcPayload::UserInput { .. }));
769 }
770}