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 pub signature: Option<Vec<u8>>,
17 pub source_id: Uuid,
19 pub timestamp: DateTime<Utc>,
21 #[serde(default)]
24 pub seq: u64,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub principal: Option<String>,
32}
33
34impl IpcMessage {
35 #[must_use]
37 pub fn new(topic: impl Into<String>, payload: IpcPayload, source_id: Uuid) -> Self {
38 Self {
39 topic: topic.into(),
40 payload,
41 signature: None,
42 source_id,
43 timestamp: Utc::now(),
44 seq: 0,
45 principal: None,
46 }
47 }
48
49 #[must_use]
51 pub fn with_signature(mut self, signature: Vec<u8>) -> Self {
52 self.signature = Some(signature);
53 self
54 }
55
56 #[must_use]
58 pub fn with_principal(mut self, principal: impl Into<String>) -> Self {
59 self.principal = Some(principal.into());
60 self
61 }
62}
63
64fn default_session_id() -> String {
66 "default".into()
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
71#[serde(tag = "type", rename_all = "snake_case")]
72pub enum IpcPayload {
73 RawJson(Value),
75 UserInput {
77 text: String,
79 #[serde(default = "default_session_id")]
81 session_id: String,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 context: Option<Value>,
85 },
86 AgentResponse {
88 text: String,
90 is_final: bool,
92 #[serde(default = "default_session_id")]
94 session_id: String,
95 },
96 ApprovalRequired {
98 request_id: String,
100 action: String,
102 resource: String,
104 reason: String,
106 risk_level: String,
108 },
109 ApprovalResponse {
111 request_id: String,
113 decision: String,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 reason: Option<String>,
118 },
119 OnboardingRequired {
121 capsule_id: String,
123 fields: Vec<OnboardingField>,
125 },
126 LlmRequest {
128 request_id: Uuid,
130 model: String,
132 messages: Vec<crate::llm::Message>,
134 tools: Vec<crate::llm::LlmToolDefinition>,
136 system: String,
138 },
139 LlmStreamEvent {
141 request_id: Uuid,
143 event: crate::llm::StreamEvent,
145 },
146 LlmResponse {
148 request_id: Uuid,
150 response: crate::llm::LlmResponse,
152 },
153 ToolExecuteRequest {
155 call_id: String,
157 tool_name: String,
159 arguments: Value,
161 },
162 ToolExecuteResult {
164 call_id: String,
166 result: crate::llm::ToolCallResult,
168 },
169 ToolCancelRequest {
171 call_ids: Vec<String>,
173 },
174 SelectionRequired {
176 request_id: String,
178 title: String,
180 options: Vec<SelectionOption>,
182 callback_topic: String,
184 },
185 ElicitRequest {
187 request_id: Uuid,
189 capsule_id: String,
191 field: OnboardingField,
193 },
194 ElicitResponse {
196 request_id: Uuid,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 value: Option<String>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 values: Option<Vec<String>>,
204 },
205 Connect,
207 Disconnect {
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 reason: Option<String>,
212 },
213 Custom {
215 data: Value,
217 },
218 #[serde(other)]
220 Unknown,
221}
222
223impl IpcPayload {
224 #[must_use]
226 pub fn is_known_tag(tag: &str) -> bool {
227 matches!(
228 tag,
229 "raw_json"
230 | "user_input"
231 | "agent_response"
232 | "approval_required"
233 | "approval_response"
234 | "onboarding_required"
235 | "llm_request"
236 | "llm_stream_event"
237 | "llm_response"
238 | "tool_execute_request"
239 | "tool_execute_result"
240 | "tool_cancel_request"
241 | "selection_required"
242 | "elicit_request"
243 | "elicit_response"
244 | "connect"
245 | "disconnect"
246 | "custom"
247 )
248 }
249
250 pub fn from_json_value(data: Value) -> Self {
253 let is_known = data
254 .get("type")
255 .and_then(|v| v.as_str())
256 .is_some_and(Self::is_known_tag);
257
258 if is_known {
259 serde_json::from_value::<Self>(data.clone()).unwrap_or(Self::Custom { data })
260 } else {
261 Self::Custom { data }
262 }
263 }
264
265 pub fn to_guest_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
275 match self {
276 Self::Custom { data } | Self::RawJson(data) => serde_json::to_vec(data),
277 other => serde_json::to_vec(other),
278 }
279 }
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
284pub struct SelectionOption {
285 pub id: String,
287 pub label: String,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub description: Option<String>,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
296pub struct OnboardingField {
297 pub key: String,
299 pub prompt: String,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub description: Option<String>,
304 pub field_type: OnboardingFieldType,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub default: Option<String>,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub placeholder: Option<String>,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
316pub enum OnboardingFieldType {
317 Text,
319 Secret,
321 Enum(Vec<String>),
323 Array,
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 #[test]
332 fn ipc_message_signature() {
333 let msg = IpcMessage::new(
334 "test.topic",
335 IpcPayload::AgentResponse {
336 text: "hello".into(),
337 is_final: true,
338 session_id: "default".into(),
339 },
340 Uuid::new_v4(),
341 );
342 assert!(msg.signature.is_none());
343
344 let signed = msg.with_signature(vec![1, 2, 3]);
345 assert_eq!(signed.signature, Some(vec![1, 2, 3]));
346 }
347
348 #[test]
349 fn ipc_message_principal() {
350 let msg = IpcMessage::new(
351 "test.topic",
352 IpcPayload::Custom {
353 data: serde_json::json!({}),
354 },
355 Uuid::new_v4(),
356 );
357 assert!(msg.principal.is_none());
358
359 let with_principal = msg.with_principal("alice");
360 assert_eq!(with_principal.principal.as_deref(), Some("alice"));
361 }
362
363 #[test]
364 fn ipc_message_principal_serde_roundtrip() {
365 let msg = IpcMessage::new(
366 "test.topic",
367 IpcPayload::Custom {
368 data: serde_json::json!({}),
369 },
370 Uuid::nil(),
371 )
372 .with_principal("bob");
373 let json = serde_json::to_string(&msg).unwrap();
374 assert!(json.contains(r#""principal":"bob""#));
375
376 let parsed: IpcMessage = serde_json::from_str(&json).unwrap();
377 assert_eq!(parsed.principal.as_deref(), Some("bob"));
378 }
379
380 #[test]
381 fn ipc_message_principal_absent_in_json() {
382 let json = r#"{"topic":"t","payload":{"type":"connect"},"source_id":"00000000-0000-0000-0000-000000000000","timestamp":"2024-01-01T00:00:00Z","seq":0}"#;
384 let msg: IpcMessage = serde_json::from_str(json).unwrap();
385 assert!(msg.principal.is_none());
386 }
387
388 #[test]
389 fn ipc_message_principal_not_serialized_when_none() {
390 let msg = IpcMessage::new("test.topic", IpcPayload::Connect, Uuid::nil());
391 let json = serde_json::to_string(&msg).unwrap();
392 assert!(!json.contains("principal"));
393 }
394
395 #[test]
396 fn unknown_type_tag_deserializes_to_unknown() {
397 let json = r#"{"type":"future_variant","some_data":42}"#;
398 let payload: IpcPayload = serde_json::from_str(json).unwrap();
399 assert_eq!(payload, IpcPayload::Unknown);
400 }
401
402 #[test]
403 fn known_variants_unaffected_by_unknown() {
404 let payload = IpcPayload::AgentResponse {
405 text: "hello".into(),
406 is_final: true,
407 session_id: "s1".into(),
408 };
409 let json = serde_json::to_string(&payload).unwrap();
410 let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
411 assert_eq!(parsed, payload);
412 }
413
414 #[test]
415 fn unknown_variant_serializes_as_type_unknown() {
416 let json = serde_json::to_string(&IpcPayload::Unknown).unwrap();
417 assert_eq!(json, r#"{"type":"unknown"}"#);
418 }
419
420 #[test]
424 fn is_known_tag_covers_all_variants() {
425 const EXPECTED_VARIANT_COUNT: usize = 17;
426
427 let representatives: Vec<IpcPayload> = vec![
428 IpcPayload::RawJson(serde_json::json!({"key": "val"})),
429 IpcPayload::UserInput {
430 text: String::new(),
431 session_id: "s".into(),
432 context: None,
433 },
434 IpcPayload::AgentResponse {
435 text: String::new(),
436 is_final: false,
437 session_id: "s".into(),
438 },
439 IpcPayload::ApprovalRequired {
440 request_id: "req-1".into(),
441 action: String::new(),
442 resource: String::new(),
443 reason: String::new(),
444 risk_level: "high".into(),
445 },
446 IpcPayload::ApprovalResponse {
447 request_id: "req-1".into(),
448 decision: "approve".into(),
449 reason: None,
450 },
451 IpcPayload::OnboardingRequired {
452 capsule_id: String::new(),
453 fields: vec![],
454 },
455 IpcPayload::LlmRequest {
456 request_id: Uuid::nil(),
457 model: String::new(),
458 messages: vec![],
459 tools: vec![],
460 system: String::new(),
461 },
462 IpcPayload::LlmStreamEvent {
463 request_id: Uuid::nil(),
464 event: crate::llm::StreamEvent::TextDelta(String::new()),
465 },
466 IpcPayload::LlmResponse {
467 request_id: Uuid::nil(),
468 response: crate::llm::LlmResponse {
469 message: crate::llm::Message {
470 role: crate::llm::MessageRole::Assistant,
471 content: crate::llm::MessageContent::Text(String::new()),
472 },
473 has_tool_calls: false,
474 stop_reason: crate::llm::StopReason::EndTurn,
475 usage: crate::llm::Usage {
476 input_tokens: 0,
477 output_tokens: 0,
478 },
479 },
480 },
481 IpcPayload::ToolExecuteRequest {
482 call_id: String::new(),
483 tool_name: String::new(),
484 arguments: Value::Null,
485 },
486 IpcPayload::ToolExecuteResult {
487 call_id: String::new(),
488 result: crate::llm::ToolCallResult {
489 call_id: String::new(),
490 content: String::new(),
491 is_error: false,
492 },
493 },
494 IpcPayload::SelectionRequired {
495 request_id: String::new(),
496 title: String::new(),
497 options: vec![],
498 callback_topic: String::new(),
499 },
500 IpcPayload::ElicitRequest {
501 request_id: Uuid::nil(),
502 capsule_id: String::new(),
503 field: OnboardingField {
504 key: String::new(),
505 prompt: String::new(),
506 description: None,
507 field_type: OnboardingFieldType::Text,
508 default: None,
509 placeholder: None,
510 },
511 },
512 IpcPayload::ElicitResponse {
513 request_id: Uuid::nil(),
514 value: None,
515 values: None,
516 },
517 IpcPayload::Connect,
518 IpcPayload::Disconnect { reason: None },
519 IpcPayload::Custom {
520 data: Value::Object(serde_json::Map::new()),
521 },
522 ];
523
524 assert_eq!(
525 representatives.len(),
526 EXPECTED_VARIANT_COUNT,
527 "IpcPayload variant count changed. Update the representatives list \
528 and bump EXPECTED_VARIANT_COUNT."
529 );
530
531 for variant in &representatives {
532 let json = serde_json::to_value(variant).unwrap();
533 let tag = json["type"]
534 .as_str()
535 .unwrap_or_else(|| panic!("variant {variant:?} has no `type` tag"));
536 assert!(
537 IpcPayload::is_known_tag(tag),
538 "is_known_tag does not recognise tag '{tag}' from variant {variant:?}"
539 );
540 }
541 }
542
543 #[test]
544 fn is_known_tag_rejects_unknown_tags() {
545 assert!(!IpcPayload::is_known_tag("my_plugin_msg"));
546 assert!(!IpcPayload::is_known_tag("unknown"));
547 assert!(!IpcPayload::is_known_tag(""));
548 assert!(!IpcPayload::is_known_tag("Raw_Json"));
549 }
550
551 #[test]
552 fn onboarding_field_roundtrip() {
553 let field = OnboardingField {
554 key: "apiKey".into(),
555 prompt: "Enter API key".into(),
556 description: None,
557 field_type: OnboardingFieldType::Secret,
558 default: None,
559 placeholder: None,
560 };
561 let json = serde_json::to_string(&field).unwrap();
562 let parsed: OnboardingField = serde_json::from_str(&json).unwrap();
563 assert_eq!(parsed, field);
564 }
565
566 #[test]
567 fn onboarding_field_roundtrip_array() {
568 let field = OnboardingField {
569 key: "relays".into(),
570 prompt: "Enter relay URLs".into(),
571 description: Some("Nostr relay endpoints".into()),
572 field_type: OnboardingFieldType::Array,
573 default: None,
574 placeholder: None,
575 };
576 let json = serde_json::to_string(&field).unwrap();
577 let parsed: OnboardingField = serde_json::from_str(&json).unwrap();
578 assert_eq!(parsed, field);
579 }
580
581 #[test]
582 fn onboarding_required_payload_roundtrip() {
583 let payload = IpcPayload::OnboardingRequired {
584 capsule_id: "test-capsule".into(),
585 fields: vec![
586 OnboardingField {
587 key: "network".into(),
588 prompt: "Select network".into(),
589 description: Some("Choose the target network".into()),
590 field_type: OnboardingFieldType::Enum(vec!["testnet".into(), "mainnet".into()]),
591 default: Some("testnet".into()),
592 placeholder: None,
593 },
594 OnboardingField {
595 key: "apiKey".into(),
596 prompt: "Enter API key".into(),
597 description: None,
598 field_type: OnboardingFieldType::Secret,
599 default: None,
600 placeholder: None,
601 },
602 ],
603 };
604 let json = serde_json::to_string(&payload).unwrap();
605 let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
606 assert_eq!(parsed, payload);
607 }
608
609 #[test]
610 fn elicit_request_roundtrip() {
611 let payload = IpcPayload::ElicitRequest {
612 request_id: Uuid::nil(),
613 capsule_id: "my-capsule".into(),
614 field: OnboardingField {
615 key: "api_url".into(),
616 prompt: "Enter API URL".into(),
617 description: Some("The backend endpoint".into()),
618 field_type: OnboardingFieldType::Text,
619 default: Some("https://example.com".into()),
620 placeholder: None,
621 },
622 };
623 let json = serde_json::to_string(&payload).unwrap();
624 let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
625 assert_eq!(parsed, payload);
626 }
627
628 #[test]
629 fn elicit_response_roundtrip() {
630 let payload = IpcPayload::ElicitResponse {
631 request_id: Uuid::nil(),
632 value: Some("hello".into()),
633 values: None,
634 };
635 let json = serde_json::to_string(&payload).unwrap();
636 let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
637 assert_eq!(parsed, payload);
638 }
639
640 #[test]
641 fn disconnect_with_reason_roundtrip() {
642 let payload = IpcPayload::Disconnect {
643 reason: Some("quit".into()),
644 };
645 let json = serde_json::to_string(&payload).unwrap();
646 let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
647 assert_eq!(parsed, payload);
648 assert!(json.contains(r#""type":"disconnect""#), "json: {json}");
649 }
650
651 #[test]
652 fn disconnect_without_reason_roundtrip() {
653 let payload = IpcPayload::Disconnect { reason: None };
654 let json = serde_json::to_string(&payload).unwrap();
655 let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
656 assert_eq!(parsed, payload);
657 assert!(!json.contains("reason"), "json: {json}");
658 }
659
660 #[test]
661 fn to_guest_bytes_custom_returns_inner_data() {
662 let data = serde_json::json!({"session_id": "abc", "messages": []});
663 let payload = IpcPayload::Custom { data: data.clone() };
664 let bytes = payload.to_guest_bytes().unwrap();
665 let roundtrip: Value = serde_json::from_slice(&bytes).unwrap();
666 assert_eq!(roundtrip, data);
667 assert!(roundtrip.get("type").is_none());
668 }
669
670 #[test]
671 fn to_guest_bytes_structured_preserves_type_tag() {
672 let payload = IpcPayload::UserInput {
673 text: "hello".into(),
674 session_id: "default".into(),
675 context: None,
676 };
677 let bytes = payload.to_guest_bytes().unwrap();
678 let roundtrip: Value = serde_json::from_slice(&bytes).unwrap();
679 assert_eq!(
680 roundtrip.get("type").and_then(|v| v.as_str()),
681 Some("user_input")
682 );
683 }
684
685 #[test]
686 fn to_guest_bytes_raw_json_unwraps() {
687 let inner = serde_json::json!({"key": "value"});
688 let payload = IpcPayload::RawJson(inner.clone());
689 let bytes = payload.to_guest_bytes().unwrap();
690 let roundtrip: Value = serde_json::from_slice(&bytes).unwrap();
691 assert_eq!(roundtrip, inner);
692 assert!(roundtrip.get("type").is_none());
693 }
694
695 #[test]
696 fn to_guest_bytes_connect_unit_variant() {
697 let payload = IpcPayload::Connect;
698 let bytes = payload.to_guest_bytes().unwrap();
699 let roundtrip: Value = serde_json::from_slice(&bytes).unwrap();
700 assert_eq!(
701 roundtrip.get("type").and_then(|v| v.as_str()),
702 Some("connect")
703 );
704 }
705
706 #[test]
707 fn from_json_value_unknown_tag_becomes_custom() {
708 let data = serde_json::json!({"type": "my_plugin_msg", "foo": 42});
709 let payload = IpcPayload::from_json_value(data.clone());
710 assert_eq!(payload, IpcPayload::Custom { data });
711 }
712
713 #[test]
714 fn from_json_value_known_tag_parses() {
715 let data = serde_json::json!({
716 "type": "user_input",
717 "text": "hi",
718 "session_id": "s1"
719 });
720 let payload = IpcPayload::from_json_value(data);
721 assert!(matches!(payload, IpcPayload::UserInput { .. }));
722 }
723}