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