1use std::collections::HashMap;
10use std::fmt;
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "snake_case")]
19pub enum InteractionKind {
20 DraftReview,
22 ApprovalDiscussion,
24 PlanNegotiation,
26 Escalation,
28 AgentQuestion,
30 Custom(String),
32}
33
34impl fmt::Display for InteractionKind {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 match self {
37 InteractionKind::DraftReview => write!(f, "draft_review"),
38 InteractionKind::ApprovalDiscussion => write!(f, "approval_discussion"),
39 InteractionKind::PlanNegotiation => write!(f, "plan_negotiation"),
40 InteractionKind::Escalation => write!(f, "escalation"),
41 InteractionKind::AgentQuestion => write!(f, "agent_question"),
42 InteractionKind::Custom(name) => write!(f, "custom:{}", name),
43 }
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49#[serde(rename_all = "snake_case")]
50pub enum Urgency {
51 Blocking,
53 Advisory,
55 Informational,
57}
58
59impl fmt::Display for Urgency {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 match self {
62 Urgency::Blocking => write!(f, "blocking"),
63 Urgency::Advisory => write!(f, "advisory"),
64 Urgency::Informational => write!(f, "informational"),
65 }
66 }
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct InteractionRequest {
72 pub interaction_id: Uuid,
74
75 pub kind: InteractionKind,
77
78 pub context: serde_json::Value,
82
83 pub urgency: Urgency,
85
86 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
89 pub metadata: HashMap<String, String>,
90
91 pub created_at: DateTime<Utc>,
93
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub goal_id: Option<Uuid>,
97}
98
99impl InteractionRequest {
100 pub fn new(kind: InteractionKind, context: serde_json::Value, urgency: Urgency) -> Self {
102 Self {
103 interaction_id: Uuid::new_v4(),
104 kind,
105 context,
106 urgency,
107 metadata: HashMap::new(),
108 created_at: Utc::now(),
109 goal_id: None,
110 }
111 }
112
113 pub fn with_goal_id(mut self, goal_id: Uuid) -> Self {
115 self.goal_id = Some(goal_id);
116 self
117 }
118
119 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
121 self.metadata.insert(key.into(), value.into());
122 self
123 }
124
125 pub fn draft_review(draft_id: Uuid, summary: &str, artifact_count: usize) -> Self {
127 Self::new(
128 InteractionKind::DraftReview,
129 serde_json::json!({
130 "draft_id": draft_id.to_string(),
131 "summary": summary,
132 "artifact_count": artifact_count,
133 }),
134 Urgency::Blocking,
135 )
136 }
137
138 pub fn plan_negotiation(phase: &str, proposed_status: &str) -> Self {
140 Self::new(
141 InteractionKind::PlanNegotiation,
142 serde_json::json!({
143 "phase": phase,
144 "proposed_status": proposed_status,
145 }),
146 Urgency::Blocking,
147 )
148 }
149
150 pub fn escalation(reason: &str, details: serde_json::Value) -> Self {
152 Self::new(
153 InteractionKind::Escalation,
154 serde_json::json!({
155 "reason": reason,
156 "details": details,
157 }),
158 Urgency::Blocking,
159 )
160 }
161}
162
163impl fmt::Display for InteractionRequest {
164 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165 write!(
166 f,
167 "[{}] {} (urgency: {})",
168 self.interaction_id, self.kind, self.urgency
169 )
170 }
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
175#[serde(rename_all = "snake_case", tag = "decision")]
176pub enum Decision {
177 Approve,
179 Reject { reason: String },
181 Discuss,
183 SkipForNow,
185}
186
187impl fmt::Display for Decision {
188 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189 match self {
190 Decision::Approve => write!(f, "approved"),
191 Decision::Reject { reason } => write!(f, "rejected: {}", reason),
192 Decision::Discuss => write!(f, "discuss"),
193 Decision::SkipForNow => write!(f, "skipped"),
194 }
195 }
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct InteractionResponse {
201 pub interaction_id: Uuid,
203
204 pub decision: Decision,
206
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub reasoning: Option<String>,
210
211 pub responded_at: DateTime<Utc>,
213
214 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub responder_id: Option<String>,
217}
218
219impl InteractionResponse {
220 pub fn new(interaction_id: Uuid, decision: Decision) -> Self {
222 Self {
223 interaction_id,
224 decision,
225 reasoning: None,
226 responded_at: Utc::now(),
227 responder_id: None,
228 }
229 }
230
231 pub fn with_reasoning(mut self, reasoning: impl Into<String>) -> Self {
233 self.reasoning = Some(reasoning.into());
234 self
235 }
236
237 pub fn with_responder(mut self, responder_id: impl Into<String>) -> Self {
239 self.responder_id = Some(responder_id.into());
240 self
241 }
242}
243
244impl fmt::Display for InteractionResponse {
245 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246 write!(f, "[{}] {}", self.interaction_id, self.decision)
247 }
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct Notification {
254 pub notification_id: Uuid,
256
257 pub message: String,
259
260 pub level: NotificationLevel,
262
263 pub created_at: DateTime<Utc>,
265
266 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub goal_id: Option<Uuid>,
269}
270
271impl Notification {
272 pub fn new(message: impl Into<String>, level: NotificationLevel) -> Self {
274 Self {
275 notification_id: Uuid::new_v4(),
276 message: message.into(),
277 level,
278 created_at: Utc::now(),
279 goal_id: None,
280 }
281 }
282
283 pub fn with_goal_id(mut self, goal_id: Uuid) -> Self {
285 self.goal_id = Some(goal_id);
286 self
287 }
288
289 pub fn info(message: impl Into<String>) -> Self {
291 Self::new(message, NotificationLevel::Info)
292 }
293
294 pub fn warning(message: impl Into<String>) -> Self {
296 Self::new(message, NotificationLevel::Warning)
297 }
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
302#[serde(rename_all = "snake_case")]
303pub enum NotificationLevel {
304 Debug,
305 Info,
306 Warning,
307 Error,
308}
309
310impl fmt::Display for NotificationLevel {
311 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
312 match self {
313 NotificationLevel::Debug => write!(f, "debug"),
314 NotificationLevel::Info => write!(f, "info"),
315 NotificationLevel::Warning => write!(f, "warning"),
316 NotificationLevel::Error => write!(f, "error"),
317 }
318 }
319}
320
321#[derive(Debug, Clone, Default, Serialize, Deserialize)]
323pub struct ChannelCapabilities {
324 pub supports_async: bool,
326
327 pub supports_rich_media: bool,
329
330 pub supports_threads: bool,
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn interaction_request_creation() {
340 let req = InteractionRequest::draft_review(Uuid::new_v4(), "Test draft", 3);
341 assert_eq!(req.kind, InteractionKind::DraftReview);
342 assert_eq!(req.urgency, Urgency::Blocking);
343 assert_eq!(req.context["artifact_count"], 3);
344 assert_eq!(req.context["summary"], "Test draft");
345 }
346
347 #[test]
348 fn interaction_request_with_metadata() {
349 let req = InteractionRequest::draft_review(Uuid::new_v4(), "Test", 1)
350 .with_metadata("color", "yellow")
351 .with_goal_id(Uuid::new_v4());
352 assert_eq!(req.metadata.get("color").unwrap(), "yellow");
353 assert!(req.goal_id.is_some());
354 }
355
356 #[test]
357 fn plan_negotiation_request() {
358 let req = InteractionRequest::plan_negotiation("v0.4.2", "done");
359 assert_eq!(req.kind, InteractionKind::PlanNegotiation);
360 assert_eq!(req.context["phase"], "v0.4.2");
361 assert_eq!(req.context["proposed_status"], "done");
362 }
363
364 #[test]
365 fn escalation_request() {
366 let req = InteractionRequest::escalation(
367 "exceeded token budget",
368 serde_json::json!({"budget": 10000, "used": 15000}),
369 );
370 assert_eq!(req.kind, InteractionKind::Escalation);
371 assert_eq!(req.context["reason"], "exceeded token budget");
372 }
373
374 #[test]
375 fn interaction_response_creation() {
376 let id = Uuid::new_v4();
377 let resp = InteractionResponse::new(id, Decision::Approve)
378 .with_reasoning("looks good")
379 .with_responder("cli:tty0");
380 assert_eq!(resp.interaction_id, id);
381 assert_eq!(resp.decision, Decision::Approve);
382 assert_eq!(resp.reasoning.as_deref(), Some("looks good"));
383 assert_eq!(resp.responder_id.as_deref(), Some("cli:tty0"));
384 }
385
386 #[test]
387 fn decision_display() {
388 assert_eq!(format!("{}", Decision::Approve), "approved");
389 assert_eq!(
390 format!(
391 "{}",
392 Decision::Reject {
393 reason: "missing tests".into()
394 }
395 ),
396 "rejected: missing tests"
397 );
398 assert_eq!(format!("{}", Decision::Discuss), "discuss");
399 assert_eq!(format!("{}", Decision::SkipForNow), "skipped");
400 }
401
402 #[test]
403 fn notification_creation() {
404 let goal_id = Uuid::new_v4();
405 let notif = Notification::info("Sub-goal 2 of 5 started").with_goal_id(goal_id);
406 assert_eq!(notif.level, NotificationLevel::Info);
407 assert_eq!(notif.goal_id, Some(goal_id));
408 }
409
410 #[test]
411 fn interaction_request_serialization_round_trip() {
412 let req = InteractionRequest::draft_review(Uuid::new_v4(), "Test", 2)
413 .with_metadata("thread_id", "T123");
414 let json = serde_json::to_string(&req).unwrap();
415 let restored: InteractionRequest = serde_json::from_str(&json).unwrap();
416 assert_eq!(restored.interaction_id, req.interaction_id);
417 assert_eq!(restored.kind, InteractionKind::DraftReview);
418 assert_eq!(restored.metadata.get("thread_id").unwrap(), "T123");
419 }
420
421 #[test]
422 fn interaction_response_serialization_round_trip() {
423 let resp = InteractionResponse::new(
424 Uuid::new_v4(),
425 Decision::Reject {
426 reason: "needs refactor".into(),
427 },
428 )
429 .with_reasoning("too complex");
430 let json = serde_json::to_string(&resp).unwrap();
431 let restored: InteractionResponse = serde_json::from_str(&json).unwrap();
432 assert_eq!(restored.decision, resp.decision);
433 assert_eq!(restored.reasoning.as_deref(), Some("too complex"));
434 }
435
436 #[test]
437 fn notification_serialization_round_trip() {
438 let notif = Notification::warning("Drift detected");
439 let json = serde_json::to_string(¬if).unwrap();
440 let restored: Notification = serde_json::from_str(&json).unwrap();
441 assert_eq!(restored.message, "Drift detected");
442 assert_eq!(restored.level, NotificationLevel::Warning);
443 }
444
445 #[test]
446 fn channel_capabilities_defaults() {
447 let caps = ChannelCapabilities::default();
448 assert!(!caps.supports_async);
449 assert!(!caps.supports_rich_media);
450 assert!(!caps.supports_threads);
451 }
452
453 #[test]
454 fn interaction_kind_custom() {
455 let kind = InteractionKind::Custom("webhook_alert".into());
456 assert_eq!(format!("{}", kind), "custom:webhook_alert");
457
458 let json = serde_json::to_string(&kind).unwrap();
459 let restored: InteractionKind = serde_json::from_str(&json).unwrap();
460 assert_eq!(restored, kind);
461 }
462
463 #[test]
464 fn interaction_request_display() {
465 let req = InteractionRequest::draft_review(Uuid::new_v4(), "Test", 1);
466 let display = format!("{}", req);
467 assert!(display.contains("draft_review"));
468 assert!(display.contains("blocking"));
469 }
470}