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