1use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use uuid::Uuid;
10
11use crate::types::{ContentBlock, HandlingMode, RenderMetadata};
12
13#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub struct InteractionId(#[cfg_attr(feature = "schema", schemars(with = "String"))] pub Uuid);
17
18impl std::fmt::Display for InteractionId {
19 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20 self.0.fmt(f)
21 }
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum ResponseStatus {
30 Accepted,
31 Completed,
32 Failed,
33}
34
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40#[serde(tag = "type", rename_all = "snake_case")]
41pub enum InteractionContent {
42 Message {
44 body: String,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 blocks: Option<Vec<ContentBlock>>,
48 },
49 Request { intent: String, params: Value },
51 Response {
53 in_reply_to: InteractionId,
54 status: ResponseStatus,
55 result: Value,
56 },
57}
58
59#[derive(Debug, Clone)]
61pub struct InboxInteraction {
62 pub id: InteractionId,
64 pub from: String,
66 pub content: InteractionContent,
68 pub rendered_text: String,
70 pub handling_mode: HandlingMode,
72 pub render_metadata: Option<RenderMetadata>,
74}
75
76pub fn format_external_event_projection(source_name: &str, body: Option<&str>) -> String {
82 let label = format!("[EVENT via {source_name}]");
83 let body = body.map(str::trim).filter(|body| !body.is_empty());
84
85 match body {
86 Some(body) => format!("{label} {body}"),
87 None => label,
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum PeerInputClass {
97 ActionableMessage,
99 ActionableRequest,
101 Response,
103 PeerLifecycleAdded,
105 PeerLifecycleRetired,
107 PeerLifecycleUnwired,
109 PeerLifecycleKickoffFailed,
111 PeerLifecycleKickoffCancelled,
113 SilentRequest,
115 Ack,
117 PlainEvent,
119}
120
121impl PeerInputClass {
122 pub fn is_actionable(&self) -> bool {
124 matches!(
125 self,
126 Self::ActionableMessage
127 | Self::ActionableRequest
128 | Self::Response
129 | Self::PlainEvent
130 | Self::PeerLifecycleKickoffFailed
131 | Self::PeerLifecycleKickoffCancelled
132 )
133 }
134}
135
136#[derive(Debug, Clone)]
142pub struct PeerInputCandidate {
143 pub interaction: InboxInteraction,
145 pub class: PeerInputClass,
147 pub lifecycle_peer: Option<String>,
149}
150
151#[cfg(test)]
152#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
153mod tests {
154 use super::*;
155
156 #[test]
157 fn interaction_id_json_roundtrip() {
158 let id = InteractionId(Uuid::new_v4());
159 let json = serde_json::to_string(&id).unwrap();
160 let parsed: InteractionId = serde_json::from_str(&json).unwrap();
161 assert_eq!(id, parsed);
162 }
163
164 #[test]
165 fn interaction_content_message_json_roundtrip() {
166 let content = InteractionContent::Message {
167 body: "hello".to_string(),
168 blocks: None,
169 };
170 let json = serde_json::to_value(&content).unwrap();
171 assert_eq!(json["type"], "message");
172 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
173 assert_eq!(content, parsed);
174 }
175
176 #[test]
177 fn interaction_content_request_json_roundtrip() {
178 let content = InteractionContent::Request {
179 intent: "review".to_string(),
180 params: serde_json::json!({"pr": 42}),
181 };
182 let json = serde_json::to_value(&content).unwrap();
183 assert_eq!(json["type"], "request");
184 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
185 assert_eq!(content, parsed);
186 }
187
188 #[test]
189 fn interaction_content_response_json_roundtrip() {
190 let id = InteractionId(Uuid::new_v4());
191 let content = InteractionContent::Response {
192 in_reply_to: id,
193 status: ResponseStatus::Completed,
194 result: serde_json::json!({"ok": true}),
195 };
196 let json = serde_json::to_value(&content).unwrap();
197 assert_eq!(json["type"], "response");
198 assert_eq!(json["status"], "completed");
199 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
200 assert_eq!(content, parsed);
201 }
202
203 #[test]
204 fn response_status_json_roundtrip_all_variants() {
205 for (variant, expected_str) in [
206 (ResponseStatus::Accepted, "accepted"),
207 (ResponseStatus::Completed, "completed"),
208 (ResponseStatus::Failed, "failed"),
209 ] {
210 let json = serde_json::to_value(variant).unwrap();
211 assert_eq!(json, expected_str);
212 let parsed: ResponseStatus = serde_json::from_value(json).unwrap();
213 assert_eq!(variant, parsed);
214 }
215 }
216
217 #[test]
218 fn interaction_message_with_blocks_roundtrip() {
219 let content = InteractionContent::Message {
220 body: "hello".to_string(),
221 blocks: Some(vec![
222 ContentBlock::Text {
223 text: "hello".to_string(),
224 },
225 ContentBlock::Image {
226 media_type: "image/png".to_string(),
227 data: "iVBORw0KGgo=".into(),
228 },
229 ]),
230 };
231 let json = serde_json::to_value(&content).unwrap();
232 assert_eq!(json["type"], "message");
233 assert!(json["blocks"].is_array());
234 let parsed: InteractionContent = serde_json::from_value(json).unwrap();
235 assert_eq!(content, parsed);
236 }
237
238 #[test]
239 fn inbox_interaction_preserves_runtime_hints() {
240 let interaction = InboxInteraction {
241 id: InteractionId(Uuid::new_v4()),
242 from: "event:webhook".into(),
243 content: InteractionContent::Message {
244 body: "hello".into(),
245 blocks: None,
246 },
247 rendered_text: "[EVENT via webhook] hello".into(),
248 handling_mode: HandlingMode::Steer,
249 render_metadata: Some(RenderMetadata {
250 class: crate::types::RenderClass::SystemNotice,
251 salience: crate::types::RenderSalience::Urgent,
252 }),
253 };
254
255 assert_eq!(interaction.handling_mode, HandlingMode::Steer);
256 assert!(interaction.render_metadata.is_some());
257 }
258
259 #[test]
260 fn interaction_message_without_blocks_compat() {
261 let old_json = r#"{"type":"message","body":"hello"}"#;
263 let parsed: InteractionContent = serde_json::from_str(old_json).unwrap();
264 match parsed {
265 InteractionContent::Message { body, blocks } => {
266 assert_eq!(body, "hello");
267 assert_eq!(blocks, None);
268 }
269 other => panic!("Expected Message, got {other:?}"),
270 }
271
272 let content = InteractionContent::Message {
274 body: "test".to_string(),
275 blocks: None,
276 };
277 let json = serde_json::to_string(&content).unwrap();
278 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
279 assert!(
280 value.get("blocks").is_none(),
281 "blocks: None should not appear in JSON"
282 );
283 }
284}