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. Only filled in by the `clock` feature
24    /// path (kernel-side); when the feature is off (capsule SDK
25    /// consumption on `wasm32-unknown-unknown`), missing timestamps
26    /// fall back to the Unix epoch — capsules read timestamps from
27    /// kernel-published messages, they never construct fresh ones.
28    #[cfg_attr(feature = "clock", serde(default = "Utc::now"))]
29    #[cfg_attr(not(feature = "clock"), serde(default = "default_unix_epoch"))]
30    pub timestamp: DateTime<Utc>,
31    /// Monotonic sequence number assigned by the event bus at publish time.
32    /// Used by the dispatcher to guarantee in-order delivery per capsule.
33    #[serde(default)]
34    pub seq: u64,
35    /// The principal (user identity) this message is acting on behalf of.
36    ///
37    /// `String` rather than `PrincipalId` because `astrid-types` must not
38    /// depend on `astrid-core`. Validation to `PrincipalId` happens at the
39    /// kernel boundary. `None` for system events (boot, lifecycle).
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub principal: Option<String>,
42}
43
44/// `DateTime<Utc>` at the Unix epoch — used as the serde default for
45/// `timestamp` fields when the `clock` feature is off and a message
46/// arrives without one. Capsule-side code never inspects this value;
47/// kernel-side code always sets a real timestamp before publish.
48#[cfg(not(feature = "clock"))]
49fn default_unix_epoch() -> DateTime<Utc> {
50    // chrono guarantees epoch is representable; the `unwrap_or`
51    // branch is unreachable. `MIN_UTC` is the safe fallback if that
52    // contract ever changes.
53    DateTime::<Utc>::from_timestamp(0, 0).unwrap_or(DateTime::<Utc>::MIN_UTC)
54}
55
56impl IpcMessage {
57    /// Create a new IPC message stamped with the current wall-clock
58    /// time. Only available when the `clock` feature is enabled
59    /// (kernel-side); capsule code constructs `IpcMessage` from
60    /// payloads it receives, never from scratch.
61    #[cfg(feature = "clock")]
62    #[must_use]
63    pub fn new(topic: impl Into<String>, payload: IpcPayload, source_id: Uuid) -> Self {
64        Self {
65            topic: topic.into(),
66            payload,
67            signature: None,
68            source_id,
69            timestamp: Utc::now(),
70            seq: 0,
71            principal: None,
72        }
73    }
74
75    /// Attach a signature for swarm verification.
76    #[must_use]
77    pub fn with_signature(mut self, signature: Vec<u8>) -> Self {
78        self.signature = Some(signature);
79        self
80    }
81
82    /// Set the acting principal for this message.
83    #[must_use]
84    pub fn with_principal(mut self, principal: impl Into<String>) -> Self {
85        self.principal = Some(principal.into());
86        self
87    }
88}
89
90/// Default session ID for conversations.
91fn default_session_id() -> String {
92    "default".into()
93}
94
95/// Standardized cross-boundary payload schemas.
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
97#[serde(tag = "type", rename_all = "snake_case")]
98pub enum IpcPayload {
99    /// Raw, arbitrary JSON.
100    RawJson(Value),
101    /// User input provided via a frontend (CLI, Telegram).
102    UserInput {
103        /// The raw text input.
104        text: String,
105        /// Session ID for conversation continuity. Defaults to `"default"`.
106        #[serde(default = "default_session_id")]
107        session_id: String,
108        /// Optional extra context.
109        #[serde(default, skip_serializing_if = "Option::is_none")]
110        context: Option<Value>,
111    },
112    /// A response generated by an agent.
113    AgentResponse {
114        /// The text output.
115        text: String,
116        /// True if this is the final response in a chain.
117        is_final: bool,
118        /// Session ID for multi-session attribution.
119        #[serde(default = "default_session_id")]
120        session_id: String,
121    },
122    /// An interceptor or capsule request for capability approval.
123    ApprovalRequired {
124        /// Opaque correlation ID.
125        request_id: String,
126        /// The action being requested (e.g. "git push").
127        action: String,
128        /// The resource target (e.g. full command string).
129        resource: String,
130        /// Justification.
131        reason: String,
132    },
133    /// Response to an [`ApprovalRequired`](IpcPayload::ApprovalRequired).
134    ApprovalResponse {
135        /// Must match the `request_id` from the originating request.
136        request_id: String,
137        /// The user's decision.
138        decision: String,
139        /// Optional reason for the decision.
140        #[serde(default, skip_serializing_if = "Option::is_none")]
141        reason: Option<String>,
142    },
143    /// A capsule needs environment variables to be provided by the user.
144    OnboardingRequired {
145        /// The ID of the capsule requiring onboarding.
146        capsule_id: String,
147        /// Rich field descriptors for each missing env var.
148        fields: Vec<OnboardingField>,
149    },
150    /// Request an LLM provider capsule to generate a response.
151    LlmRequest {
152        /// The unique ID of the request, used for routing the response stream back.
153        request_id: Uuid,
154        /// The requested model name (e.g. "claude-3-5-sonnet").
155        model: String,
156        /// The conversation history.
157        messages: Vec<crate::llm::Message>,
158        /// The tools available to the model.
159        tools: Vec<crate::llm::LlmToolDefinition>,
160        /// The system prompt.
161        system: String,
162    },
163    /// A stream event from an LLM provider capsule.
164    LlmStreamEvent {
165        /// The unique ID of the request this stream belongs to.
166        request_id: Uuid,
167        /// The actual stream event (`TokenDelta`, `ToolCallStart`, etc).
168        event: crate::llm::StreamEvent,
169    },
170    /// The final, non-streaming LLM response.
171    LlmResponse {
172        /// The unique ID of the request this response belongs to.
173        request_id: Uuid,
174        /// The final response object.
175        response: crate::llm::LlmResponse,
176    },
177    /// Request the Tool Router capsule to execute a tool.
178    ToolExecuteRequest {
179        /// The unique ID of the tool call.
180        call_id: String,
181        /// The name of the tool to execute.
182        tool_name: String,
183        /// The JSON arguments.
184        arguments: Value,
185    },
186    /// The result of a tool execution.
187    ToolExecuteResult {
188        /// The unique ID of the tool call.
189        call_id: String,
190        /// The result of the execution.
191        result: crate::llm::ToolCallResult,
192    },
193    /// Request cancellation of in-flight tool executions.
194    ToolCancelRequest {
195        /// The call IDs of the tool invocations to cancel.
196        call_ids: Vec<String>,
197    },
198    /// A capsule is requesting the user to select from a list of options.
199    SelectionRequired {
200        /// Opaque ID so the capsule can correlate the response.
201        request_id: String,
202        /// Title/prompt shown above the list.
203        title: String,
204        /// The selectable options.
205        options: Vec<SelectionOption>,
206        /// IPC topic to publish the user's choice back on.
207        callback_topic: String,
208    },
209    /// A lifecycle hook is requesting user input via the `elicit` API.
210    ElicitRequest {
211        /// Correlation ID.
212        request_id: Uuid,
213        /// The capsule requesting input.
214        capsule_id: String,
215        /// Field descriptor reusing the onboarding schema.
216        field: OnboardingField,
217    },
218    /// Response to an [`ElicitRequest`](IpcPayload::ElicitRequest).
219    ElicitResponse {
220        /// Must match the `request_id` from the originating request.
221        request_id: Uuid,
222        /// The user's input. `None` if the user cancelled.
223        #[serde(default, skip_serializing_if = "Option::is_none")]
224        value: Option<String>,
225        /// For `Array`-type fields, the collected items.
226        #[serde(default, skip_serializing_if = "Option::is_none")]
227        values: Option<Vec<String>>,
228    },
229    /// A client has connected.
230    Connect,
231    /// A client is disconnecting gracefully.
232    Disconnect {
233        /// Optional reason for disconnection (e.g. "quit", "timeout").
234        #[serde(default, skip_serializing_if = "Option::is_none")]
235        reason: Option<String>,
236    },
237    /// Arbitrary JSON data for unstructured plugins.
238    Custom {
239        /// Raw data.
240        data: Value,
241    },
242    /// Unrecognized payload type from a newer protocol version.
243    #[serde(other)]
244    Unknown,
245}
246
247impl IpcPayload {
248    /// Returns `true` if `tag` matches a known serde variant name.
249    #[must_use]
250    pub fn is_known_tag(tag: &str) -> bool {
251        matches!(
252            tag,
253            "raw_json"
254                | "user_input"
255                | "agent_response"
256                | "approval_required"
257                | "approval_response"
258                | "onboarding_required"
259                | "llm_request"
260                | "llm_stream_event"
261                | "llm_response"
262                | "tool_execute_request"
263                | "tool_execute_result"
264                | "tool_cancel_request"
265                | "selection_required"
266                | "elicit_request"
267                | "elicit_response"
268                | "connect"
269                | "disconnect"
270                | "custom"
271        )
272    }
273
274    /// Deserialize a JSON [`Value`] into an `IpcPayload`, falling back to
275    /// [`Custom`](Self::Custom) for unrecognised or missing type tags.
276    pub fn from_json_value(data: Value) -> Self {
277        let is_known = data
278            .get("type")
279            .and_then(|v| v.as_str())
280            .is_some_and(Self::is_known_tag);
281
282        if is_known {
283            serde_json::from_value::<Self>(data.clone()).unwrap_or(Self::Custom { data })
284        } else {
285            Self::Custom { data }
286        }
287    }
288
289    /// Serialize only the guest-facing payload data.
290    ///
291    /// [`Custom`](Self::Custom) and [`RawJson`](Self::RawJson) payloads return
292    /// the inner data value directly (no `type` wrapper). Structured variants
293    /// return the full tagged serialization.
294    ///
295    /// # Errors
296    ///
297    /// Returns `serde_json::Error` if serialization fails.
298    pub fn to_guest_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
299        match self {
300            Self::Custom { data } | Self::RawJson(data) => serde_json::to_vec(data),
301            other => serde_json::to_vec(other),
302        }
303    }
304}
305
306/// A single option in a `SelectionRequired` picker.
307#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
308pub struct SelectionOption {
309    /// Machine-readable identifier sent back to the capsule.
310    pub id: String,
311    /// Human-readable label shown in the picker.
312    pub label: String,
313    /// Optional description shown alongside the label.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub description: Option<String>,
316}
317
318/// A field descriptor for capsule onboarding.
319#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
320pub struct OnboardingField {
321    /// The environment variable key.
322    pub key: String,
323    /// The prompt shown to the user.
324    pub prompt: String,
325    /// Optional description for additional context.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub description: Option<String>,
328    /// The input type for this field.
329    pub field_type: OnboardingFieldType,
330    /// Optional default value.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub default: Option<String>,
333    /// Placeholder hint text shown when the input is empty (e.g. `"sk-..."`).
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub placeholder: Option<String>,
336}
337
338/// The type of input expected for an onboarding field.
339#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
340pub enum OnboardingFieldType {
341    /// Free-form text input.
342    Text,
343    /// Masked secret input.
344    Secret,
345    /// Selection from a fixed set of choices.
346    Enum(Vec<String>),
347    /// Multi-value array input (user adds items one at a time).
348    Array,
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn ipc_message_signature() {
357        let msg = IpcMessage::new(
358            "test.topic",
359            IpcPayload::AgentResponse {
360                text: "hello".into(),
361                is_final: true,
362                session_id: "default".into(),
363            },
364            Uuid::new_v4(),
365        );
366        assert!(msg.signature.is_none());
367
368        let signed = msg.with_signature(vec![1, 2, 3]);
369        assert_eq!(signed.signature, Some(vec![1, 2, 3]));
370    }
371
372    #[test]
373    fn ipc_message_principal() {
374        let msg = IpcMessage::new(
375            "test.topic",
376            IpcPayload::Custom {
377                data: serde_json::json!({}),
378            },
379            Uuid::new_v4(),
380        );
381        assert!(msg.principal.is_none());
382
383        let with_principal = msg.with_principal("alice");
384        assert_eq!(with_principal.principal.as_deref(), Some("alice"));
385    }
386
387    #[test]
388    fn ipc_message_principal_serde_roundtrip() {
389        let msg = IpcMessage::new(
390            "test.topic",
391            IpcPayload::Custom {
392                data: serde_json::json!({}),
393            },
394            Uuid::nil(),
395        )
396        .with_principal("bob");
397        let json = serde_json::to_string(&msg).unwrap();
398        assert!(json.contains(r#""principal":"bob""#));
399
400        let parsed: IpcMessage = serde_json::from_str(&json).unwrap();
401        assert_eq!(parsed.principal.as_deref(), Some("bob"));
402    }
403
404    #[test]
405    fn ipc_message_principal_absent_in_json() {
406        // Messages without principal should deserialize with None.
407        let json = r#"{"topic":"t","payload":{"type":"connect"},"source_id":"00000000-0000-0000-0000-000000000000","timestamp":"2024-01-01T00:00:00Z","seq":0}"#;
408        let msg: IpcMessage = serde_json::from_str(json).unwrap();
409        assert!(msg.principal.is_none());
410    }
411
412    #[test]
413    fn ipc_message_principal_not_serialized_when_none() {
414        let msg = IpcMessage::new("test.topic", IpcPayload::Connect, Uuid::nil());
415        let json = serde_json::to_string(&msg).unwrap();
416        assert!(!json.contains("principal"));
417    }
418
419    #[test]
420    fn unknown_type_tag_deserializes_to_unknown() {
421        let json = r#"{"type":"future_variant","some_data":42}"#;
422        let payload: IpcPayload = serde_json::from_str(json).unwrap();
423        assert_eq!(payload, IpcPayload::Unknown);
424    }
425
426    #[test]
427    fn ipc_message_parses_cli_proxy_wire_format() {
428        // The CLI proxy capsule (capsules/astrid-capsule-cli) forwards bus
429        // messages to socket clients using only the fields exposed by the
430        // SDK's `ipc::Message`: {topic, payload, source_id}. The SDK does
431        // not surface the original timestamp or signature, so the wire
432        // format omits them. Without serde defaults on those fields the
433        // headless client's `from_slice::<IpcMessage>` silently fails on
434        // every frame and the response never reaches the user.
435        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"}"#;
436        let msg: IpcMessage = serde_json::from_str(wire).expect("cli proxy frame must parse");
437        assert_eq!(msg.topic, "agent.v1.response");
438        assert!(msg.signature.is_none());
439        assert_eq!(msg.seq, 0);
440        match msg.payload {
441            IpcPayload::AgentResponse { text, is_final, .. } => {
442                assert_eq!(text, "hi");
443                assert!(is_final);
444            },
445            other => panic!("unexpected payload variant: {other:?}"),
446        }
447    }
448
449    #[test]
450    fn known_variants_unaffected_by_unknown() {
451        let payload = IpcPayload::AgentResponse {
452            text: "hello".into(),
453            is_final: true,
454            session_id: "s1".into(),
455        };
456        let json = serde_json::to_string(&payload).unwrap();
457        let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
458        assert_eq!(parsed, payload);
459    }
460
461    #[test]
462    fn unknown_variant_serializes_as_type_unknown() {
463        let json = serde_json::to_string(&IpcPayload::Unknown).unwrap();
464        assert_eq!(json, r#"{"type":"unknown"}"#);
465    }
466
467    /// Every variant's serialized `type` tag must be recognised by
468    /// `is_known_tag`. If a new variant is added without updating the
469    /// match arm *and* the representatives list below, this test fails.
470    #[test]
471    #[allow(clippy::too_many_lines, reason = "exhaustive variant table")]
472    fn is_known_tag_covers_all_variants() {
473        const EXPECTED_VARIANT_COUNT: usize = 17;
474
475        let representatives: Vec<IpcPayload> = vec![
476            IpcPayload::RawJson(serde_json::json!({"key": "val"})),
477            IpcPayload::UserInput {
478                text: String::new(),
479                session_id: "s".into(),
480                context: None,
481            },
482            IpcPayload::AgentResponse {
483                text: String::new(),
484                is_final: false,
485                session_id: "s".into(),
486            },
487            IpcPayload::ApprovalRequired {
488                request_id: "req-1".into(),
489                action: String::new(),
490                resource: String::new(),
491                reason: String::new(),
492            },
493            IpcPayload::ApprovalResponse {
494                request_id: "req-1".into(),
495                decision: "approve".into(),
496                reason: None,
497            },
498            IpcPayload::OnboardingRequired {
499                capsule_id: String::new(),
500                fields: vec![],
501            },
502            IpcPayload::LlmRequest {
503                request_id: Uuid::nil(),
504                model: String::new(),
505                messages: vec![],
506                tools: vec![],
507                system: String::new(),
508            },
509            IpcPayload::LlmStreamEvent {
510                request_id: Uuid::nil(),
511                event: crate::llm::StreamEvent::TextDelta(String::new()),
512            },
513            IpcPayload::LlmResponse {
514                request_id: Uuid::nil(),
515                response: crate::llm::LlmResponse {
516                    message: crate::llm::Message {
517                        role: crate::llm::MessageRole::Assistant,
518                        content: crate::llm::MessageContent::Text(String::new()),
519                    },
520                    has_tool_calls: false,
521                    stop_reason: crate::llm::StopReason::EndTurn,
522                    usage: crate::llm::Usage {
523                        input_tokens: 0,
524                        output_tokens: 0,
525                    },
526                },
527            },
528            IpcPayload::ToolExecuteRequest {
529                call_id: String::new(),
530                tool_name: String::new(),
531                arguments: Value::Null,
532            },
533            IpcPayload::ToolExecuteResult {
534                call_id: String::new(),
535                result: crate::llm::ToolCallResult {
536                    call_id: String::new(),
537                    content: String::new(),
538                    is_error: false,
539                },
540            },
541            IpcPayload::SelectionRequired {
542                request_id: String::new(),
543                title: String::new(),
544                options: vec![],
545                callback_topic: String::new(),
546            },
547            IpcPayload::ElicitRequest {
548                request_id: Uuid::nil(),
549                capsule_id: String::new(),
550                field: OnboardingField {
551                    key: String::new(),
552                    prompt: String::new(),
553                    description: None,
554                    field_type: OnboardingFieldType::Text,
555                    default: None,
556                    placeholder: None,
557                },
558            },
559            IpcPayload::ElicitResponse {
560                request_id: Uuid::nil(),
561                value: None,
562                values: None,
563            },
564            IpcPayload::Connect,
565            IpcPayload::Disconnect { reason: None },
566            IpcPayload::Custom {
567                data: Value::Object(serde_json::Map::new()),
568            },
569        ];
570
571        assert_eq!(
572            representatives.len(),
573            EXPECTED_VARIANT_COUNT,
574            "IpcPayload variant count changed. Update the representatives list \
575             and bump EXPECTED_VARIANT_COUNT."
576        );
577
578        for variant in &representatives {
579            let json = serde_json::to_value(variant).unwrap();
580            let tag = json["type"]
581                .as_str()
582                .unwrap_or_else(|| panic!("variant {variant:?} has no `type` tag"));
583            assert!(
584                IpcPayload::is_known_tag(tag),
585                "is_known_tag does not recognise tag '{tag}' from variant {variant:?}"
586            );
587        }
588    }
589
590    #[test]
591    fn is_known_tag_rejects_unknown_tags() {
592        assert!(!IpcPayload::is_known_tag("my_plugin_msg"));
593        assert!(!IpcPayload::is_known_tag("unknown"));
594        assert!(!IpcPayload::is_known_tag(""));
595        assert!(!IpcPayload::is_known_tag("Raw_Json"));
596    }
597
598    #[test]
599    fn onboarding_field_roundtrip() {
600        let field = OnboardingField {
601            key: "apiKey".into(),
602            prompt: "Enter API key".into(),
603            description: None,
604            field_type: OnboardingFieldType::Secret,
605            default: None,
606            placeholder: None,
607        };
608        let json = serde_json::to_string(&field).unwrap();
609        let parsed: OnboardingField = serde_json::from_str(&json).unwrap();
610        assert_eq!(parsed, field);
611    }
612
613    #[test]
614    fn onboarding_field_roundtrip_array() {
615        let field = OnboardingField {
616            key: "relays".into(),
617            prompt: "Enter relay URLs".into(),
618            description: Some("Nostr relay endpoints".into()),
619            field_type: OnboardingFieldType::Array,
620            default: None,
621            placeholder: None,
622        };
623        let json = serde_json::to_string(&field).unwrap();
624        let parsed: OnboardingField = serde_json::from_str(&json).unwrap();
625        assert_eq!(parsed, field);
626    }
627
628    #[test]
629    fn onboarding_required_payload_roundtrip() {
630        let payload = IpcPayload::OnboardingRequired {
631            capsule_id: "test-capsule".into(),
632            fields: vec![
633                OnboardingField {
634                    key: "network".into(),
635                    prompt: "Select network".into(),
636                    description: Some("Choose the target network".into()),
637                    field_type: OnboardingFieldType::Enum(vec!["testnet".into(), "mainnet".into()]),
638                    default: Some("testnet".into()),
639                    placeholder: None,
640                },
641                OnboardingField {
642                    key: "apiKey".into(),
643                    prompt: "Enter API key".into(),
644                    description: None,
645                    field_type: OnboardingFieldType::Secret,
646                    default: None,
647                    placeholder: None,
648                },
649            ],
650        };
651        let json = serde_json::to_string(&payload).unwrap();
652        let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
653        assert_eq!(parsed, payload);
654    }
655
656    #[test]
657    fn elicit_request_roundtrip() {
658        let payload = IpcPayload::ElicitRequest {
659            request_id: Uuid::nil(),
660            capsule_id: "my-capsule".into(),
661            field: OnboardingField {
662                key: "api_url".into(),
663                prompt: "Enter API URL".into(),
664                description: Some("The backend endpoint".into()),
665                field_type: OnboardingFieldType::Text,
666                default: Some("https://example.com".into()),
667                placeholder: None,
668            },
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    }
674
675    #[test]
676    fn elicit_response_roundtrip() {
677        let payload = IpcPayload::ElicitResponse {
678            request_id: Uuid::nil(),
679            value: Some("hello".into()),
680            values: None,
681        };
682        let json = serde_json::to_string(&payload).unwrap();
683        let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
684        assert_eq!(parsed, payload);
685    }
686
687    #[test]
688    fn disconnect_with_reason_roundtrip() {
689        let payload = IpcPayload::Disconnect {
690            reason: Some("quit".into()),
691        };
692        let json = serde_json::to_string(&payload).unwrap();
693        let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
694        assert_eq!(parsed, payload);
695        assert!(json.contains(r#""type":"disconnect""#), "json: {json}");
696    }
697
698    #[test]
699    fn disconnect_without_reason_roundtrip() {
700        let payload = IpcPayload::Disconnect { reason: None };
701        let json = serde_json::to_string(&payload).unwrap();
702        let parsed: IpcPayload = serde_json::from_str(&json).unwrap();
703        assert_eq!(parsed, payload);
704        assert!(!json.contains("reason"), "json: {json}");
705    }
706
707    #[test]
708    fn to_guest_bytes_custom_returns_inner_data() {
709        let data = serde_json::json!({"session_id": "abc", "messages": []});
710        let payload = IpcPayload::Custom { data: data.clone() };
711        let bytes = payload.to_guest_bytes().unwrap();
712        let roundtrip: Value = serde_json::from_slice(&bytes).unwrap();
713        assert_eq!(roundtrip, data);
714        assert!(roundtrip.get("type").is_none());
715    }
716
717    #[test]
718    fn to_guest_bytes_structured_preserves_type_tag() {
719        let payload = IpcPayload::UserInput {
720            text: "hello".into(),
721            session_id: "default".into(),
722            context: None,
723        };
724        let bytes = payload.to_guest_bytes().unwrap();
725        let roundtrip: Value = serde_json::from_slice(&bytes).unwrap();
726        assert_eq!(
727            roundtrip.get("type").and_then(|v| v.as_str()),
728            Some("user_input")
729        );
730    }
731
732    #[test]
733    fn to_guest_bytes_raw_json_unwraps() {
734        let inner = serde_json::json!({"key": "value"});
735        let payload = IpcPayload::RawJson(inner.clone());
736        let bytes = payload.to_guest_bytes().unwrap();
737        let roundtrip: Value = serde_json::from_slice(&bytes).unwrap();
738        assert_eq!(roundtrip, inner);
739        assert!(roundtrip.get("type").is_none());
740    }
741
742    #[test]
743    fn to_guest_bytes_connect_unit_variant() {
744        let payload = IpcPayload::Connect;
745        let bytes = payload.to_guest_bytes().unwrap();
746        let roundtrip: Value = serde_json::from_slice(&bytes).unwrap();
747        assert_eq!(
748            roundtrip.get("type").and_then(|v| v.as_str()),
749            Some("connect")
750        );
751    }
752
753    #[test]
754    fn from_json_value_unknown_tag_becomes_custom() {
755        let data = serde_json::json!({"type": "my_plugin_msg", "foo": 42});
756        let payload = IpcPayload::from_json_value(data.clone());
757        assert_eq!(payload, IpcPayload::Custom { data });
758    }
759
760    #[test]
761    fn from_json_value_known_tag_parses() {
762        let data = serde_json::json!({
763            "type": "user_input",
764            "text": "hi",
765            "session_id": "s1"
766        });
767        let payload = IpcPayload::from_json_value(data);
768        assert!(matches!(payload, IpcPayload::UserInput { .. }));
769    }
770}