use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use crate::types::{ContentBlock, HandlingMode, RenderMetadata};
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct InteractionId(#[cfg_attr(feature = "schema", schemars(with = "String"))] pub Uuid);
impl std::fmt::Display for InteractionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResponseStatus {
Accepted,
Completed,
Failed,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum InteractionContent {
Message {
body: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
blocks: Option<Vec<ContentBlock>>,
},
Request { intent: String, params: Value },
Response {
in_reply_to: InteractionId,
status: ResponseStatus,
result: Value,
},
}
#[derive(Debug, Clone)]
pub struct InboxInteraction {
pub id: InteractionId,
pub from: String,
pub content: InteractionContent,
pub rendered_text: String,
pub handling_mode: HandlingMode,
pub render_metadata: Option<RenderMetadata>,
}
pub fn format_external_event_projection(source_name: &str, body: Option<&str>) -> String {
let label = format!("[EVENT via {source_name}]");
let body = body.map(str::trim).filter(|body| !body.is_empty());
match body {
Some(body) => format!("{label} {body}"),
None => label,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PeerInputClass {
ActionableMessage,
ActionableRequest,
Response,
PeerLifecycleAdded,
PeerLifecycleRetired,
SilentRequest,
Ack,
PlainEvent,
}
impl PeerInputClass {
pub fn is_actionable(&self) -> bool {
matches!(
self,
Self::ActionableMessage | Self::ActionableRequest | Self::Response | Self::PlainEvent
)
}
}
#[derive(Debug, Clone)]
pub struct ClassifiedInboxInteraction {
pub interaction: InboxInteraction,
pub class: PeerInputClass,
pub lifecycle_peer: Option<String>,
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn interaction_id_json_roundtrip() {
let id = InteractionId(Uuid::new_v4());
let json = serde_json::to_string(&id).unwrap();
let parsed: InteractionId = serde_json::from_str(&json).unwrap();
assert_eq!(id, parsed);
}
#[test]
fn interaction_content_message_json_roundtrip() {
let content = InteractionContent::Message {
body: "hello".to_string(),
blocks: None,
};
let json = serde_json::to_value(&content).unwrap();
assert_eq!(json["type"], "message");
let parsed: InteractionContent = serde_json::from_value(json).unwrap();
assert_eq!(content, parsed);
}
#[test]
fn interaction_content_request_json_roundtrip() {
let content = InteractionContent::Request {
intent: "review".to_string(),
params: serde_json::json!({"pr": 42}),
};
let json = serde_json::to_value(&content).unwrap();
assert_eq!(json["type"], "request");
let parsed: InteractionContent = serde_json::from_value(json).unwrap();
assert_eq!(content, parsed);
}
#[test]
fn interaction_content_response_json_roundtrip() {
let id = InteractionId(Uuid::new_v4());
let content = InteractionContent::Response {
in_reply_to: id,
status: ResponseStatus::Completed,
result: serde_json::json!({"ok": true}),
};
let json = serde_json::to_value(&content).unwrap();
assert_eq!(json["type"], "response");
assert_eq!(json["status"], "completed");
let parsed: InteractionContent = serde_json::from_value(json).unwrap();
assert_eq!(content, parsed);
}
#[test]
fn response_status_json_roundtrip_all_variants() {
for (variant, expected_str) in [
(ResponseStatus::Accepted, "accepted"),
(ResponseStatus::Completed, "completed"),
(ResponseStatus::Failed, "failed"),
] {
let json = serde_json::to_value(variant).unwrap();
assert_eq!(json, expected_str);
let parsed: ResponseStatus = serde_json::from_value(json).unwrap();
assert_eq!(variant, parsed);
}
}
#[test]
fn interaction_message_with_blocks_roundtrip() {
let content = InteractionContent::Message {
body: "hello".to_string(),
blocks: Some(vec![
ContentBlock::Text {
text: "hello".to_string(),
},
ContentBlock::Image {
media_type: "image/png".to_string(),
data: "iVBORw0KGgo=".into(),
},
]),
};
let json = serde_json::to_value(&content).unwrap();
assert_eq!(json["type"], "message");
assert!(json["blocks"].is_array());
let parsed: InteractionContent = serde_json::from_value(json).unwrap();
assert_eq!(content, parsed);
}
#[test]
fn inbox_interaction_preserves_runtime_hints() {
let interaction = InboxInteraction {
id: InteractionId(Uuid::new_v4()),
from: "event:webhook".into(),
content: InteractionContent::Message {
body: "hello".into(),
blocks: None,
},
rendered_text: "[EVENT via webhook] hello".into(),
handling_mode: HandlingMode::Steer,
render_metadata: Some(RenderMetadata {
class: crate::types::RenderClass::SystemNotice,
salience: crate::types::RenderSalience::Urgent,
}),
};
assert_eq!(interaction.handling_mode, HandlingMode::Steer);
assert!(interaction.render_metadata.is_some());
}
#[test]
fn interaction_message_without_blocks_compat() {
let old_json = r#"{"type":"message","body":"hello"}"#;
let parsed: InteractionContent = serde_json::from_str(old_json).unwrap();
match parsed {
InteractionContent::Message { body, blocks } => {
assert_eq!(body, "hello");
assert_eq!(blocks, None);
}
other => panic!("Expected Message, got {other:?}"),
}
let content = InteractionContent::Message {
body: "test".to_string(),
blocks: None,
};
let json = serde_json::to_string(&content).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(
value.get("blocks").is_none(),
"blocks: None should not appear in JSON"
);
}
}