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}
22
23impl IpcMessage {
24 #[must_use]
26 pub fn new(topic: impl Into<String>, payload: IpcPayload, source_id: Uuid) -> Self {
27 Self {
28 topic: topic.into(),
29 payload,
30 signature: None,
31 source_id,
32 timestamp: Utc::now(),
33 }
34 }
35
36 #[must_use]
38 pub fn with_signature(mut self, signature: Vec<u8>) -> Self {
39 self.signature = Some(signature);
40 self
41 }
42}
43
44fn default_session_id() -> String {
46 "default".into()
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51#[serde(tag = "type", rename_all = "snake_case")]
52pub enum IpcPayload {
53 RawJson(Value),
55 UserInput {
57 text: String,
59 #[serde(default = "default_session_id")]
61 session_id: String,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 context: Option<Value>,
65 },
66 AgentResponse {
68 text: String,
70 is_final: bool,
72 #[serde(default = "default_session_id")]
74 session_id: String,
75 },
76 ApprovalRequired {
78 request_id: String,
80 action: String,
82 resource: String,
84 reason: String,
86 risk_level: String,
88 },
89 ApprovalResponse {
91 request_id: String,
93 decision: String,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 reason: Option<String>,
98 },
99 OnboardingRequired {
101 capsule_id: String,
103 fields: Vec<OnboardingField>,
105 },
106 LlmRequest {
108 request_id: Uuid,
110 model: String,
112 messages: Vec<crate::llm::Message>,
114 tools: Vec<crate::llm::LlmToolDefinition>,
116 system: String,
118 },
119 LlmStreamEvent {
121 request_id: Uuid,
123 event: crate::llm::StreamEvent,
125 },
126 LlmResponse {
128 request_id: Uuid,
130 response: crate::llm::LlmResponse,
132 },
133 ToolExecuteRequest {
135 call_id: String,
137 tool_name: String,
139 arguments: Value,
141 },
142 ToolExecuteResult {
144 call_id: String,
146 result: crate::llm::ToolCallResult,
148 },
149 ToolCancelRequest {
151 call_ids: Vec<String>,
153 },
154 SelectionRequired {
156 request_id: String,
158 title: String,
160 options: Vec<SelectionOption>,
162 callback_topic: String,
164 },
165 ElicitRequest {
167 request_id: Uuid,
169 capsule_id: String,
171 field: OnboardingField,
173 },
174 ElicitResponse {
176 request_id: Uuid,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
180 value: Option<String>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 values: Option<Vec<String>>,
184 },
185 Connect,
187 Disconnect {
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 reason: Option<String>,
192 },
193 Custom {
195 data: Value,
197 },
198 #[serde(other)]
200 Unknown,
201}
202
203impl IpcPayload {
204 #[must_use]
206 pub fn is_known_tag(tag: &str) -> bool {
207 matches!(
208 tag,
209 "raw_json"
210 | "user_input"
211 | "agent_response"
212 | "approval_required"
213 | "approval_response"
214 | "onboarding_required"
215 | "llm_request"
216 | "llm_stream_event"
217 | "llm_response"
218 | "tool_execute_request"
219 | "tool_execute_result"
220 | "tool_cancel_request"
221 | "selection_required"
222 | "elicit_request"
223 | "elicit_response"
224 | "connect"
225 | "disconnect"
226 | "custom"
227 )
228 }
229
230 pub fn from_json_value(data: Value) -> Self {
233 let is_known = data
234 .get("type")
235 .and_then(|v| v.as_str())
236 .is_some_and(Self::is_known_tag);
237
238 if is_known {
239 serde_json::from_value::<Self>(data.clone()).unwrap_or(Self::Custom { data })
240 } else {
241 Self::Custom { data }
242 }
243 }
244
245 pub fn to_guest_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
255 match self {
256 Self::Custom { data } | Self::RawJson(data) => serde_json::to_vec(data),
257 other => serde_json::to_vec(other),
258 }
259 }
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
264pub struct SelectionOption {
265 pub id: String,
267 pub label: String,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub description: Option<String>,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
276pub struct OnboardingField {
277 pub key: String,
279 pub prompt: String,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub description: Option<String>,
284 pub field_type: OnboardingFieldType,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub default: Option<String>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub placeholder: Option<String>,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
296pub enum OnboardingFieldType {
297 Text,
299 Secret,
301 Enum(Vec<String>),
303 Array,
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn ipc_message_signature() {
313 let msg = IpcMessage::new(
314 "test.topic",
315 IpcPayload::AgentResponse {
316 text: "hello".into(),
317 is_final: true,
318 session_id: "default".into(),
319 },
320 Uuid::new_v4(),
321 );
322 assert!(msg.signature.is_none());
323
324 let signed = msg.with_signature(vec![1, 2, 3]);
325 assert_eq!(signed.signature, Some(vec![1, 2, 3]));
326 }
327
328 #[test]
329 fn unknown_type_tag_deserializes_to_unknown() {
330 let json = r#"{"type":"future_variant","some_data":42}"#;
331 let payload: IpcPayload = serde_json::from_str(json).unwrap();
332 assert_eq!(payload, IpcPayload::Unknown);
333 }
334
335 #[test]
336 fn known_variants_unaffected_by_unknown() {
337 let payload = IpcPayload::AgentResponse {
338 text: "hello".into(),
339 is_final: true,
340 session_id: "s1".into(),
341 };
342 let json = serde_json::to_string(&payload).unwrap();
343 let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
344 assert_eq!(parsed, payload);
345 }
346
347 #[test]
348 fn unknown_variant_serializes_as_type_unknown() {
349 let json = serde_json::to_string(&IpcPayload::Unknown).unwrap();
350 assert_eq!(json, r#"{"type":"unknown"}"#);
351 }
352
353 #[test]
357 fn is_known_tag_covers_all_variants() {
358 const EXPECTED_VARIANT_COUNT: usize = 17;
359
360 let representatives: Vec<IpcPayload> = vec![
361 IpcPayload::RawJson(serde_json::json!({"key": "val"})),
362 IpcPayload::UserInput {
363 text: String::new(),
364 session_id: "s".into(),
365 context: None,
366 },
367 IpcPayload::AgentResponse {
368 text: String::new(),
369 is_final: false,
370 session_id: "s".into(),
371 },
372 IpcPayload::ApprovalRequired {
373 request_id: "req-1".into(),
374 action: String::new(),
375 resource: String::new(),
376 reason: String::new(),
377 risk_level: "high".into(),
378 },
379 IpcPayload::ApprovalResponse {
380 request_id: "req-1".into(),
381 decision: "approve".into(),
382 reason: None,
383 },
384 IpcPayload::OnboardingRequired {
385 capsule_id: String::new(),
386 fields: vec![],
387 },
388 IpcPayload::LlmRequest {
389 request_id: Uuid::nil(),
390 model: String::new(),
391 messages: vec![],
392 tools: vec![],
393 system: String::new(),
394 },
395 IpcPayload::LlmStreamEvent {
396 request_id: Uuid::nil(),
397 event: crate::llm::StreamEvent::TextDelta(String::new()),
398 },
399 IpcPayload::LlmResponse {
400 request_id: Uuid::nil(),
401 response: crate::llm::LlmResponse {
402 message: crate::llm::Message {
403 role: crate::llm::MessageRole::Assistant,
404 content: crate::llm::MessageContent::Text(String::new()),
405 },
406 has_tool_calls: false,
407 stop_reason: crate::llm::StopReason::EndTurn,
408 usage: crate::llm::Usage {
409 input_tokens: 0,
410 output_tokens: 0,
411 },
412 },
413 },
414 IpcPayload::ToolExecuteRequest {
415 call_id: String::new(),
416 tool_name: String::new(),
417 arguments: Value::Null,
418 },
419 IpcPayload::ToolExecuteResult {
420 call_id: String::new(),
421 result: crate::llm::ToolCallResult {
422 call_id: String::new(),
423 content: String::new(),
424 is_error: false,
425 },
426 },
427 IpcPayload::SelectionRequired {
428 request_id: String::new(),
429 title: String::new(),
430 options: vec![],
431 callback_topic: String::new(),
432 },
433 IpcPayload::ElicitRequest {
434 request_id: Uuid::nil(),
435 capsule_id: String::new(),
436 field: OnboardingField {
437 key: String::new(),
438 prompt: String::new(),
439 description: None,
440 field_type: OnboardingFieldType::Text,
441 default: None,
442 placeholder: None,
443 },
444 },
445 IpcPayload::ElicitResponse {
446 request_id: Uuid::nil(),
447 value: None,
448 values: None,
449 },
450 IpcPayload::Connect,
451 IpcPayload::Disconnect { reason: None },
452 IpcPayload::Custom {
453 data: Value::Object(serde_json::Map::new()),
454 },
455 ];
456
457 assert_eq!(
458 representatives.len(),
459 EXPECTED_VARIANT_COUNT,
460 "IpcPayload variant count changed. Update the representatives list \
461 and bump EXPECTED_VARIANT_COUNT."
462 );
463
464 for variant in &representatives {
465 let json = serde_json::to_value(variant).unwrap();
466 let tag = json["type"]
467 .as_str()
468 .unwrap_or_else(|| panic!("variant {variant:?} has no `type` tag"));
469 assert!(
470 IpcPayload::is_known_tag(tag),
471 "is_known_tag does not recognise tag '{tag}' from variant {variant:?}"
472 );
473 }
474 }
475
476 #[test]
477 fn is_known_tag_rejects_unknown_tags() {
478 assert!(!IpcPayload::is_known_tag("my_plugin_msg"));
479 assert!(!IpcPayload::is_known_tag("unknown"));
480 assert!(!IpcPayload::is_known_tag(""));
481 assert!(!IpcPayload::is_known_tag("Raw_Json"));
482 }
483
484 #[test]
485 fn onboarding_field_roundtrip() {
486 let field = OnboardingField {
487 key: "apiKey".into(),
488 prompt: "Enter API key".into(),
489 description: None,
490 field_type: OnboardingFieldType::Secret,
491 default: None,
492 placeholder: None,
493 };
494 let json = serde_json::to_string(&field).unwrap();
495 let parsed: OnboardingField = serde_json::from_str(&json).unwrap();
496 assert_eq!(parsed, field);
497 }
498
499 #[test]
500 fn onboarding_field_roundtrip_array() {
501 let field = OnboardingField {
502 key: "relays".into(),
503 prompt: "Enter relay URLs".into(),
504 description: Some("Nostr relay endpoints".into()),
505 field_type: OnboardingFieldType::Array,
506 default: None,
507 placeholder: None,
508 };
509 let json = serde_json::to_string(&field).unwrap();
510 let parsed: OnboardingField = serde_json::from_str(&json).unwrap();
511 assert_eq!(parsed, field);
512 }
513
514 #[test]
515 fn onboarding_required_payload_roundtrip() {
516 let payload = IpcPayload::OnboardingRequired {
517 capsule_id: "test-capsule".into(),
518 fields: vec![
519 OnboardingField {
520 key: "network".into(),
521 prompt: "Select network".into(),
522 description: Some("Choose the target network".into()),
523 field_type: OnboardingFieldType::Enum(vec!["testnet".into(), "mainnet".into()]),
524 default: Some("testnet".into()),
525 placeholder: None,
526 },
527 OnboardingField {
528 key: "apiKey".into(),
529 prompt: "Enter API key".into(),
530 description: None,
531 field_type: OnboardingFieldType::Secret,
532 default: None,
533 placeholder: None,
534 },
535 ],
536 };
537 let json = serde_json::to_string(&payload).unwrap();
538 let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
539 assert_eq!(parsed, payload);
540 }
541
542 #[test]
543 fn elicit_request_roundtrip() {
544 let payload = IpcPayload::ElicitRequest {
545 request_id: Uuid::nil(),
546 capsule_id: "my-capsule".into(),
547 field: OnboardingField {
548 key: "api_url".into(),
549 prompt: "Enter API URL".into(),
550 description: Some("The backend endpoint".into()),
551 field_type: OnboardingFieldType::Text,
552 default: Some("https://example.com".into()),
553 placeholder: None,
554 },
555 };
556 let json = serde_json::to_string(&payload).unwrap();
557 let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
558 assert_eq!(parsed, payload);
559 }
560
561 #[test]
562 fn elicit_response_roundtrip() {
563 let payload = IpcPayload::ElicitResponse {
564 request_id: Uuid::nil(),
565 value: Some("hello".into()),
566 values: None,
567 };
568 let json = serde_json::to_string(&payload).unwrap();
569 let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
570 assert_eq!(parsed, payload);
571 }
572
573 #[test]
574 fn disconnect_with_reason_roundtrip() {
575 let payload = IpcPayload::Disconnect {
576 reason: Some("quit".into()),
577 };
578 let json = serde_json::to_string(&payload).unwrap();
579 let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
580 assert_eq!(parsed, payload);
581 assert!(json.contains(r#""type":"disconnect""#), "json: {json}");
582 }
583
584 #[test]
585 fn disconnect_without_reason_roundtrip() {
586 let payload = IpcPayload::Disconnect { reason: None };
587 let json = serde_json::to_string(&payload).unwrap();
588 let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
589 assert_eq!(parsed, payload);
590 assert!(!json.contains("reason"), "json: {json}");
591 }
592
593 #[test]
594 fn to_guest_bytes_custom_returns_inner_data() {
595 let data = serde_json::json!({"session_id": "abc", "messages": []});
596 let payload = IpcPayload::Custom { data: data.clone() };
597 let bytes = payload.to_guest_bytes().unwrap();
598 let roundtrip: Value = serde_json::from_slice(&bytes).unwrap();
599 assert_eq!(roundtrip, data);
600 assert!(roundtrip.get("type").is_none());
601 }
602
603 #[test]
604 fn to_guest_bytes_structured_preserves_type_tag() {
605 let payload = IpcPayload::UserInput {
606 text: "hello".into(),
607 session_id: "default".into(),
608 context: None,
609 };
610 let bytes = payload.to_guest_bytes().unwrap();
611 let roundtrip: Value = serde_json::from_slice(&bytes).unwrap();
612 assert_eq!(
613 roundtrip.get("type").and_then(|v| v.as_str()),
614 Some("user_input")
615 );
616 }
617
618 #[test]
619 fn to_guest_bytes_raw_json_unwraps() {
620 let inner = serde_json::json!({"key": "value"});
621 let payload = IpcPayload::RawJson(inner.clone());
622 let bytes = payload.to_guest_bytes().unwrap();
623 let roundtrip: Value = serde_json::from_slice(&bytes).unwrap();
624 assert_eq!(roundtrip, inner);
625 assert!(roundtrip.get("type").is_none());
626 }
627
628 #[test]
629 fn to_guest_bytes_connect_unit_variant() {
630 let payload = IpcPayload::Connect;
631 let bytes = payload.to_guest_bytes().unwrap();
632 let roundtrip: Value = serde_json::from_slice(&bytes).unwrap();
633 assert_eq!(
634 roundtrip.get("type").and_then(|v| v.as_str()),
635 Some("connect")
636 );
637 }
638
639 #[test]
640 fn from_json_value_unknown_tag_becomes_custom() {
641 let data = serde_json::json!({"type": "my_plugin_msg", "foo": 42});
642 let payload = IpcPayload::from_json_value(data.clone());
643 assert_eq!(payload, IpcPayload::Custom { data });
644 }
645
646 #[test]
647 fn from_json_value_known_tag_parses() {
648 let data = serde_json::json!({
649 "type": "user_input",
650 "text": "hi",
651 "session_id": "s1"
652 });
653 let payload = IpcPayload::from_json_value(data);
654 assert!(matches!(payload, IpcPayload::UserInput { .. }));
655 }
656}