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}
22
23impl IpcMessage {
24    /// Create a new IPC message.
25    #[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    /// Attach a signature for swarm verification.
37    #[must_use]
38    pub fn with_signature(mut self, signature: Vec<u8>) -> Self {
39        self.signature = Some(signature);
40        self
41    }
42}
43
44/// Default session ID for conversations.
45fn default_session_id() -> String {
46    "default".into()
47}
48
49/// Standardized cross-boundary payload schemas.
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51#[serde(tag = "type", rename_all = "snake_case")]
52pub enum IpcPayload {
53    /// Raw, arbitrary JSON.
54    RawJson(Value),
55    /// User input provided via a frontend (CLI, Telegram).
56    UserInput {
57        /// The raw text input.
58        text: String,
59        /// Session ID for conversation continuity. Defaults to `"default"`.
60        #[serde(default = "default_session_id")]
61        session_id: String,
62        /// Optional extra context.
63        #[serde(default, skip_serializing_if = "Option::is_none")]
64        context: Option<Value>,
65    },
66    /// A response generated by an agent.
67    AgentResponse {
68        /// The text output.
69        text: String,
70        /// True if this is the final response in a chain.
71        is_final: bool,
72        /// Session ID for multi-session attribution.
73        #[serde(default = "default_session_id")]
74        session_id: String,
75    },
76    /// An interceptor or capsule request for capability approval.
77    ApprovalRequired {
78        /// Opaque correlation ID.
79        request_id: String,
80        /// The action being requested (e.g. "git push").
81        action: String,
82        /// The resource target (e.g. full command string).
83        resource: String,
84        /// Justification.
85        reason: String,
86        /// Risk classification: "low", "medium", "high", or "critical".
87        risk_level: String,
88    },
89    /// Response to an [`ApprovalRequired`](IpcPayload::ApprovalRequired).
90    ApprovalResponse {
91        /// Must match the `request_id` from the originating request.
92        request_id: String,
93        /// The user's decision.
94        decision: String,
95        /// Optional reason for the decision.
96        #[serde(default, skip_serializing_if = "Option::is_none")]
97        reason: Option<String>,
98    },
99    /// A capsule needs environment variables to be provided by the user.
100    OnboardingRequired {
101        /// The ID of the capsule requiring onboarding.
102        capsule_id: String,
103        /// Rich field descriptors for each missing env var.
104        fields: Vec<OnboardingField>,
105    },
106    /// Request an LLM provider capsule to generate a response.
107    LlmRequest {
108        /// The unique ID of the request, used for routing the response stream back.
109        request_id: Uuid,
110        /// The requested model name (e.g. "claude-3-5-sonnet").
111        model: String,
112        /// The conversation history.
113        messages: Vec<crate::llm::Message>,
114        /// The tools available to the model.
115        tools: Vec<crate::llm::LlmToolDefinition>,
116        /// The system prompt.
117        system: String,
118    },
119    /// A stream event from an LLM provider capsule.
120    LlmStreamEvent {
121        /// The unique ID of the request this stream belongs to.
122        request_id: Uuid,
123        /// The actual stream event (`TokenDelta`, `ToolCallStart`, etc).
124        event: crate::llm::StreamEvent,
125    },
126    /// The final, non-streaming LLM response.
127    LlmResponse {
128        /// The unique ID of the request this response belongs to.
129        request_id: Uuid,
130        /// The final response object.
131        response: crate::llm::LlmResponse,
132    },
133    /// Request the Tool Router capsule to execute a tool.
134    ToolExecuteRequest {
135        /// The unique ID of the tool call.
136        call_id: String,
137        /// The name of the tool to execute.
138        tool_name: String,
139        /// The JSON arguments.
140        arguments: Value,
141    },
142    /// The result of a tool execution.
143    ToolExecuteResult {
144        /// The unique ID of the tool call.
145        call_id: String,
146        /// The result of the execution.
147        result: crate::llm::ToolCallResult,
148    },
149    /// Request cancellation of in-flight tool executions.
150    ToolCancelRequest {
151        /// The call IDs of the tool invocations to cancel.
152        call_ids: Vec<String>,
153    },
154    /// A capsule is requesting the user to select from a list of options.
155    SelectionRequired {
156        /// Opaque ID so the capsule can correlate the response.
157        request_id: String,
158        /// Title/prompt shown above the list.
159        title: String,
160        /// The selectable options.
161        options: Vec<SelectionOption>,
162        /// IPC topic to publish the user's choice back on.
163        callback_topic: String,
164    },
165    /// A lifecycle hook is requesting user input via the `elicit` API.
166    ElicitRequest {
167        /// Correlation ID.
168        request_id: Uuid,
169        /// The capsule requesting input.
170        capsule_id: String,
171        /// Field descriptor reusing the onboarding schema.
172        field: OnboardingField,
173    },
174    /// Response to an [`ElicitRequest`](IpcPayload::ElicitRequest).
175    ElicitResponse {
176        /// Must match the `request_id` from the originating request.
177        request_id: Uuid,
178        /// The user's input. `None` if the user cancelled.
179        #[serde(default, skip_serializing_if = "Option::is_none")]
180        value: Option<String>,
181        /// For `Array`-type fields, the collected items.
182        #[serde(default, skip_serializing_if = "Option::is_none")]
183        values: Option<Vec<String>>,
184    },
185    /// A client has connected.
186    Connect,
187    /// A client is disconnecting gracefully.
188    Disconnect {
189        /// Optional reason for disconnection (e.g. "quit", "timeout").
190        #[serde(default, skip_serializing_if = "Option::is_none")]
191        reason: Option<String>,
192    },
193    /// Arbitrary JSON data for unstructured plugins.
194    Custom {
195        /// Raw data.
196        data: Value,
197    },
198    /// Unrecognized payload type from a newer protocol version.
199    #[serde(other)]
200    Unknown,
201}
202
203impl IpcPayload {
204    /// Returns `true` if `tag` matches a known serde variant name.
205    #[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    /// Deserialize a JSON [`Value`] into an `IpcPayload`, falling back to
231    /// [`Custom`](Self::Custom) for unrecognised or missing type tags.
232    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    /// Serialize only the guest-facing payload data.
246    ///
247    /// [`Custom`](Self::Custom) and [`RawJson`](Self::RawJson) payloads return
248    /// the inner data value directly (no `type` wrapper). Structured variants
249    /// return the full tagged serialization.
250    ///
251    /// # Errors
252    ///
253    /// Returns `serde_json::Error` if serialization fails.
254    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/// A single option in a `SelectionRequired` picker.
263#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
264pub struct SelectionOption {
265    /// Machine-readable identifier sent back to the capsule.
266    pub id: String,
267    /// Human-readable label shown in the picker.
268    pub label: String,
269    /// Optional description shown alongside the label.
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub description: Option<String>,
272}
273
274/// A field descriptor for capsule onboarding.
275#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
276pub struct OnboardingField {
277    /// The environment variable key.
278    pub key: String,
279    /// The prompt shown to the user.
280    pub prompt: String,
281    /// Optional description for additional context.
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub description: Option<String>,
284    /// The input type for this field.
285    pub field_type: OnboardingFieldType,
286    /// Optional default value.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub default: Option<String>,
289    /// Placeholder hint text shown when the input is empty (e.g. `"sk-..."`).
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub placeholder: Option<String>,
292}
293
294/// The type of input expected for an onboarding field.
295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
296pub enum OnboardingFieldType {
297    /// Free-form text input.
298    Text,
299    /// Masked secret input.
300    Secret,
301    /// Selection from a fixed set of choices.
302    Enum(Vec<String>),
303    /// Multi-value array input (user adds items one at a time).
304    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    /// Every variant's serialized `type` tag must be recognised by
354    /// `is_known_tag`. If a new variant is added without updating the
355    /// match arm *and* the representatives list below, this test fails.
356    #[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}