Skip to main content

astrid_types/
ipc.rs

1//! Cross-boundary IPC message schemas and payloads.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use uuid::Uuid;
7
8/// A cross-boundary message sent over the event bus between WASM guests and the host.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct IpcMessage {
11    /// Topic pattern or exact match (e.g., `astrid.cli.input`).
12    pub topic: String,
13    /// Standardized payload structure.
14    pub payload: IpcPayload,
15    /// Optional cryptographic signature for stateless verification across a distributed swarm.
16    pub signature: Option<Vec<u8>>,
17    /// Identifier of the sender plugin or agent.
18    pub source_id: Uuid,
19    /// Timestamp when the message was dispatched.
20    pub timestamp: DateTime<Utc>,
21    /// Monotonic sequence number assigned by the event bus at publish time.
22    /// Used by the dispatcher to guarantee in-order delivery per capsule.
23    #[serde(default)]
24    pub seq: u64,
25    /// The principal (user identity) this message is acting on behalf of.
26    ///
27    /// `String` rather than `PrincipalId` because `astrid-types` must not
28    /// depend on `astrid-core`. Validation to `PrincipalId` happens at the
29    /// kernel boundary. `None` for system events (boot, lifecycle).
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub principal: Option<String>,
32}
33
34impl IpcMessage {
35    /// Create a new IPC message.
36    #[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    /// Attach a signature for swarm verification.
50    #[must_use]
51    pub fn with_signature(mut self, signature: Vec<u8>) -> Self {
52        self.signature = Some(signature);
53        self
54    }
55
56    /// Set the acting principal for this message.
57    #[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
64/// Default session ID for conversations.
65fn default_session_id() -> String {
66    "default".into()
67}
68
69/// Standardized cross-boundary payload schemas.
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
71#[serde(tag = "type", rename_all = "snake_case")]
72pub enum IpcPayload {
73    /// Raw, arbitrary JSON.
74    RawJson(Value),
75    /// User input provided via a frontend (CLI, Telegram).
76    UserInput {
77        /// The raw text input.
78        text: String,
79        /// Session ID for conversation continuity. Defaults to `"default"`.
80        #[serde(default = "default_session_id")]
81        session_id: String,
82        /// Optional extra context.
83        #[serde(default, skip_serializing_if = "Option::is_none")]
84        context: Option<Value>,
85    },
86    /// A response generated by an agent.
87    AgentResponse {
88        /// The text output.
89        text: String,
90        /// True if this is the final response in a chain.
91        is_final: bool,
92        /// Session ID for multi-session attribution.
93        #[serde(default = "default_session_id")]
94        session_id: String,
95    },
96    /// An interceptor or capsule request for capability approval.
97    ApprovalRequired {
98        /// Opaque correlation ID.
99        request_id: String,
100        /// The action being requested (e.g. "git push").
101        action: String,
102        /// The resource target (e.g. full command string).
103        resource: String,
104        /// Justification.
105        reason: String,
106        /// Risk classification: "low", "medium", "high", or "critical".
107        risk_level: String,
108    },
109    /// Response to an [`ApprovalRequired`](IpcPayload::ApprovalRequired).
110    ApprovalResponse {
111        /// Must match the `request_id` from the originating request.
112        request_id: String,
113        /// The user's decision.
114        decision: String,
115        /// Optional reason for the decision.
116        #[serde(default, skip_serializing_if = "Option::is_none")]
117        reason: Option<String>,
118    },
119    /// A capsule needs environment variables to be provided by the user.
120    OnboardingRequired {
121        /// The ID of the capsule requiring onboarding.
122        capsule_id: String,
123        /// Rich field descriptors for each missing env var.
124        fields: Vec<OnboardingField>,
125    },
126    /// Request an LLM provider capsule to generate a response.
127    LlmRequest {
128        /// The unique ID of the request, used for routing the response stream back.
129        request_id: Uuid,
130        /// The requested model name (e.g. "claude-3-5-sonnet").
131        model: String,
132        /// The conversation history.
133        messages: Vec<crate::llm::Message>,
134        /// The tools available to the model.
135        tools: Vec<crate::llm::LlmToolDefinition>,
136        /// The system prompt.
137        system: String,
138    },
139    /// A stream event from an LLM provider capsule.
140    LlmStreamEvent {
141        /// The unique ID of the request this stream belongs to.
142        request_id: Uuid,
143        /// The actual stream event (`TokenDelta`, `ToolCallStart`, etc).
144        event: crate::llm::StreamEvent,
145    },
146    /// The final, non-streaming LLM response.
147    LlmResponse {
148        /// The unique ID of the request this response belongs to.
149        request_id: Uuid,
150        /// The final response object.
151        response: crate::llm::LlmResponse,
152    },
153    /// Request the Tool Router capsule to execute a tool.
154    ToolExecuteRequest {
155        /// The unique ID of the tool call.
156        call_id: String,
157        /// The name of the tool to execute.
158        tool_name: String,
159        /// The JSON arguments.
160        arguments: Value,
161    },
162    /// The result of a tool execution.
163    ToolExecuteResult {
164        /// The unique ID of the tool call.
165        call_id: String,
166        /// The result of the execution.
167        result: crate::llm::ToolCallResult,
168    },
169    /// Request cancellation of in-flight tool executions.
170    ToolCancelRequest {
171        /// The call IDs of the tool invocations to cancel.
172        call_ids: Vec<String>,
173    },
174    /// A capsule is requesting the user to select from a list of options.
175    SelectionRequired {
176        /// Opaque ID so the capsule can correlate the response.
177        request_id: String,
178        /// Title/prompt shown above the list.
179        title: String,
180        /// The selectable options.
181        options: Vec<SelectionOption>,
182        /// IPC topic to publish the user's choice back on.
183        callback_topic: String,
184    },
185    /// A lifecycle hook is requesting user input via the `elicit` API.
186    ElicitRequest {
187        /// Correlation ID.
188        request_id: Uuid,
189        /// The capsule requesting input.
190        capsule_id: String,
191        /// Field descriptor reusing the onboarding schema.
192        field: OnboardingField,
193    },
194    /// Response to an [`ElicitRequest`](IpcPayload::ElicitRequest).
195    ElicitResponse {
196        /// Must match the `request_id` from the originating request.
197        request_id: Uuid,
198        /// The user's input. `None` if the user cancelled.
199        #[serde(default, skip_serializing_if = "Option::is_none")]
200        value: Option<String>,
201        /// For `Array`-type fields, the collected items.
202        #[serde(default, skip_serializing_if = "Option::is_none")]
203        values: Option<Vec<String>>,
204    },
205    /// A client has connected.
206    Connect,
207    /// A client is disconnecting gracefully.
208    Disconnect {
209        /// Optional reason for disconnection (e.g. "quit", "timeout").
210        #[serde(default, skip_serializing_if = "Option::is_none")]
211        reason: Option<String>,
212    },
213    /// Arbitrary JSON data for unstructured plugins.
214    Custom {
215        /// Raw data.
216        data: Value,
217    },
218    /// Unrecognized payload type from a newer protocol version.
219    #[serde(other)]
220    Unknown,
221}
222
223impl IpcPayload {
224    /// Returns `true` if `tag` matches a known serde variant name.
225    #[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    /// Deserialize a JSON [`Value`] into an `IpcPayload`, falling back to
251    /// [`Custom`](Self::Custom) for unrecognised or missing type tags.
252    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    /// Serialize only the guest-facing payload data.
266    ///
267    /// [`Custom`](Self::Custom) and [`RawJson`](Self::RawJson) payloads return
268    /// the inner data value directly (no `type` wrapper). Structured variants
269    /// return the full tagged serialization.
270    ///
271    /// # Errors
272    ///
273    /// Returns `serde_json::Error` if serialization fails.
274    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/// A single option in a `SelectionRequired` picker.
283#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
284pub struct SelectionOption {
285    /// Machine-readable identifier sent back to the capsule.
286    pub id: String,
287    /// Human-readable label shown in the picker.
288    pub label: String,
289    /// Optional description shown alongside the label.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub description: Option<String>,
292}
293
294/// A field descriptor for capsule onboarding.
295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
296pub struct OnboardingField {
297    /// The environment variable key.
298    pub key: String,
299    /// The prompt shown to the user.
300    pub prompt: String,
301    /// Optional description for additional context.
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub description: Option<String>,
304    /// The input type for this field.
305    pub field_type: OnboardingFieldType,
306    /// Optional default value.
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub default: Option<String>,
309    /// Placeholder hint text shown when the input is empty (e.g. `"sk-..."`).
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub placeholder: Option<String>,
312}
313
314/// The type of input expected for an onboarding field.
315#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
316pub enum OnboardingFieldType {
317    /// Free-form text input.
318    Text,
319    /// Masked secret input.
320    Secret,
321    /// Selection from a fixed set of choices.
322    Enum(Vec<String>),
323    /// Multi-value array input (user adds items one at a time).
324    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        // Messages without principal should deserialize with None.
383        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    /// Every variant's serialized `type` tag must be recognised by
421    /// `is_known_tag`. If a new variant is added without updating the
422    /// match arm *and* the representatives list below, this test fails.
423    #[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}