use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IpcMessage {
pub topic: String,
pub payload: IpcPayload,
pub signature: Option<Vec<u8>>,
pub source_id: Uuid,
pub timestamp: DateTime<Utc>,
}
impl IpcMessage {
#[must_use]
pub fn new(topic: impl Into<String>, payload: IpcPayload, source_id: Uuid) -> Self {
Self {
topic: topic.into(),
payload,
signature: None,
source_id,
timestamp: Utc::now(),
}
}
#[must_use]
pub fn with_signature(mut self, signature: Vec<u8>) -> Self {
self.signature = Some(signature);
self
}
}
fn default_session_id() -> String {
"default".into()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum IpcPayload {
RawJson(Value),
UserInput {
text: String,
#[serde(default = "default_session_id")]
session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
context: Option<Value>,
},
AgentResponse {
text: String,
is_final: bool,
#[serde(default = "default_session_id")]
session_id: String,
},
ApprovalRequired {
request_id: String,
action: String,
resource: String,
reason: String,
risk_level: String,
},
ApprovalResponse {
request_id: String,
decision: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
OnboardingRequired {
capsule_id: String,
fields: Vec<OnboardingField>,
},
LlmRequest {
request_id: Uuid,
model: String,
messages: Vec<crate::llm::Message>,
tools: Vec<crate::llm::LlmToolDefinition>,
system: String,
},
LlmStreamEvent {
request_id: Uuid,
event: crate::llm::StreamEvent,
},
LlmResponse {
request_id: Uuid,
response: crate::llm::LlmResponse,
},
ToolExecuteRequest {
call_id: String,
tool_name: String,
arguments: Value,
},
ToolExecuteResult {
call_id: String,
result: crate::llm::ToolCallResult,
},
ToolCancelRequest {
call_ids: Vec<String>,
},
SelectionRequired {
request_id: String,
title: String,
options: Vec<SelectionOption>,
callback_topic: String,
},
ElicitRequest {
request_id: Uuid,
capsule_id: String,
field: OnboardingField,
},
ElicitResponse {
request_id: Uuid,
#[serde(default, skip_serializing_if = "Option::is_none")]
value: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
values: Option<Vec<String>>,
},
Connect,
Disconnect {
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
Custom {
data: Value,
},
#[serde(other)]
Unknown,
}
impl IpcPayload {
#[must_use]
pub fn is_known_tag(tag: &str) -> bool {
matches!(
tag,
"raw_json"
| "user_input"
| "agent_response"
| "approval_required"
| "approval_response"
| "onboarding_required"
| "llm_request"
| "llm_stream_event"
| "llm_response"
| "tool_execute_request"
| "tool_execute_result"
| "tool_cancel_request"
| "selection_required"
| "elicit_request"
| "elicit_response"
| "connect"
| "disconnect"
| "custom"
)
}
pub fn from_json_value(data: Value) -> Self {
let is_known = data
.get("type")
.and_then(|v| v.as_str())
.is_some_and(Self::is_known_tag);
if is_known {
serde_json::from_value::<Self>(data.clone()).unwrap_or(Self::Custom { data })
} else {
Self::Custom { data }
}
}
pub fn to_guest_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
match self {
Self::Custom { data } | Self::RawJson(data) => serde_json::to_vec(data),
other => serde_json::to_vec(other),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SelectionOption {
pub id: String,
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct OnboardingField {
pub key: String,
pub prompt: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub field_type: OnboardingFieldType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placeholder: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum OnboardingFieldType {
Text,
Secret,
Enum(Vec<String>),
Array,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ipc_message_signature() {
let msg = IpcMessage::new(
"test.topic",
IpcPayload::AgentResponse {
text: "hello".into(),
is_final: true,
session_id: "default".into(),
},
Uuid::new_v4(),
);
assert!(msg.signature.is_none());
let signed = msg.with_signature(vec![1, 2, 3]);
assert_eq!(signed.signature, Some(vec![1, 2, 3]));
}
#[test]
fn unknown_type_tag_deserializes_to_unknown() {
let json = r#"{"type":"future_variant","some_data":42}"#;
let payload: IpcPayload = serde_json::from_str(json).unwrap();
assert_eq!(payload, IpcPayload::Unknown);
}
#[test]
fn onboarding_field_roundtrip() {
let field = OnboardingField {
key: "apiKey".into(),
prompt: "Enter API key".into(),
description: None,
field_type: OnboardingFieldType::Secret,
default: None,
placeholder: None,
};
let json = serde_json::to_string(&field).unwrap();
let parsed: OnboardingField = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, field);
}
#[test]
fn to_guest_bytes_custom_returns_inner_data() {
let data = serde_json::json!({"session_id": "abc", "messages": []});
let payload = IpcPayload::Custom { data: data.clone() };
let bytes = payload.to_guest_bytes().unwrap();
let roundtrip: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(roundtrip, data);
assert!(roundtrip.get("type").is_none());
}
}