Skip to main content

ta_changeset/
interaction.rs

1// interaction.rs — Interaction request/response model for ReviewChannel.
2//
3// These types define the protocol for bidirectional human-agent communication.
4// An InteractionRequest is sent when TA needs human input (draft review, plan
5// approval, escalation), and InteractionResponse carries the human's decision.
6//
7// This is the core protocol for v0.4.1.1 (Runtime Channel Architecture).
8
9use std::collections::HashMap;
10use std::fmt;
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16/// What kind of interaction is being requested.
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "snake_case")]
19pub enum InteractionKind {
20    /// A draft is ready for review — human should approve, reject, or discuss.
21    DraftReview,
22    /// General approval question (e.g., "proceed with this approach?").
23    ApprovalDiscussion,
24    /// Agent proposes a plan change — human should accept or reject.
25    PlanNegotiation,
26    /// Agent is escalating an issue that exceeds its authority.
27    Escalation,
28    /// Agent is asking the human a question mid-execution.
29    AgentQuestion,
30    /// Extension point for future interaction types.
31    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/// How urgent is this interaction?
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49#[serde(rename_all = "snake_case")]
50pub enum Urgency {
51    /// Agent blocks until human responds.
52    Blocking,
53    /// Agent can continue, but human should respond eventually.
54    Advisory,
55    /// Informational only — no response expected.
56    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/// A request from TA to the human, delivered via a ReviewChannel.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct InteractionRequest {
72    /// Unique identifier for this interaction (for correlation with response).
73    pub interaction_id: Uuid,
74
75    /// What kind of interaction this is.
76    pub kind: InteractionKind,
77
78    /// Structured payload — contents depend on `kind`.
79    /// For DraftReview: { "draft_id": "...", "summary": "...", "artifact_count": N }
80    /// For PlanNegotiation: { "phase": "...", "proposed_status": "..." }
81    pub context: serde_json::Value,
82
83    /// How urgent is this interaction?
84    pub urgency: Urgency,
85
86    /// Arbitrary key-value pairs for channel-specific rendering hints.
87    /// e.g., { "color": "yellow", "thread_id": "..." }
88    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
89    pub metadata: HashMap<String, String>,
90
91    /// When the request was created.
92    pub created_at: DateTime<Utc>,
93
94    /// Optional goal ID for context.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub goal_id: Option<Uuid>,
97}
98
99impl InteractionRequest {
100    /// Create a new interaction request.
101    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    /// Set the goal ID for this interaction.
114    pub fn with_goal_id(mut self, goal_id: Uuid) -> Self {
115        self.goal_id = Some(goal_id);
116        self
117    }
118
119    /// Add a metadata key-value pair.
120    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    /// Create a DraftReview interaction request.
126    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    /// Create a PlanNegotiation interaction request.
139    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    /// Create an Escalation interaction request.
151    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/// The human's decision in response to an InteractionRequest.
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
175#[serde(rename_all = "snake_case", tag = "decision")]
176pub enum Decision {
177    /// Approved — proceed as proposed.
178    Approve,
179    /// Rejected — do not proceed, with explanation.
180    Reject { reason: String },
181    /// Human wants to discuss further before deciding.
182    Discuss,
183    /// Skip this interaction for now (non-blocking interactions only).
184    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/// The human's response to an InteractionRequest.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct InteractionResponse {
201    /// Correlation ID — must match the InteractionRequest.interaction_id.
202    pub interaction_id: Uuid,
203
204    /// The human's decision.
205    pub decision: Decision,
206
207    /// Optional free-text reasoning or feedback.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub reasoning: Option<String>,
210
211    /// When the response was created.
212    pub responded_at: DateTime<Utc>,
213
214    /// Who responded (channel identity, e.g., "cli:tty0", "slack:U12345").
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub responder_id: Option<String>,
217}
218
219impl InteractionResponse {
220    /// Create a new response for a given interaction.
221    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    /// Set reasoning text.
232    pub fn with_reasoning(mut self, reasoning: impl Into<String>) -> Self {
233        self.reasoning = Some(reasoning.into());
234        self
235    }
236
237    /// Set responder identity.
238    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/// A non-blocking notification from TA to the human.
251/// Unlike InteractionRequest, no response is expected.
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct Notification {
254    /// Unique notification ID.
255    pub notification_id: Uuid,
256
257    /// Human-readable message.
258    pub message: String,
259
260    /// Severity level for rendering.
261    pub level: NotificationLevel,
262
263    /// When the notification was created.
264    pub created_at: DateTime<Utc>,
265
266    /// Optional goal ID for context.
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub goal_id: Option<Uuid>,
269}
270
271impl Notification {
272    /// Create a new notification.
273    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    /// Set the goal ID.
284    pub fn with_goal_id(mut self, goal_id: Uuid) -> Self {
285        self.goal_id = Some(goal_id);
286        self
287    }
288
289    /// Create an info notification.
290    pub fn info(message: impl Into<String>) -> Self {
291        Self::new(message, NotificationLevel::Info)
292    }
293
294    /// Create a warning notification.
295    pub fn warning(message: impl Into<String>) -> Self {
296        Self::new(message, NotificationLevel::Warning)
297    }
298}
299
300/// Notification severity level.
301#[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/// Describes what a ReviewChannel implementation supports.
322#[derive(Debug, Clone, Default, Serialize, Deserialize)]
323pub struct ChannelCapabilities {
324    /// Whether the channel supports async responses (human responds later, not inline).
325    pub supports_async: bool,
326
327    /// Whether the channel supports rich media (images, formatted diffs, etc.).
328    pub supports_rich_media: bool,
329
330    /// Whether the channel supports threaded discussions.
331    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(&notif).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}