Skip to main content

ta_changeset/
review_channel.rs

1// review_channel.rs — ReviewChannel trait for pluggable human-agent communication.
2//
3// Unlike SessionChannel (which streams agent output), ReviewChannel is for
4// bidirectional interactions where TA needs human input: draft review, plan
5// approval, escalation, etc. Implementations can target any medium — terminal,
6// Slack, Discord, email, webhook.
7//
8// This is the core abstraction for v0.4.1.1 (Runtime Channel Architecture).
9// Future adapters (v0.5.3) implement this same trait for non-terminal mediums.
10
11use std::fmt;
12
13use crate::interaction::{
14    ChannelCapabilities, InteractionRequest, InteractionResponse, Notification,
15};
16
17/// Errors from ReviewChannel operations.
18#[derive(Debug, thiserror::Error)]
19pub enum ReviewChannelError {
20    #[error("I/O error: {0}")]
21    Io(#[from] std::io::Error),
22
23    #[error("channel closed")]
24    ChannelClosed,
25
26    #[error("channel timeout")]
27    Timeout,
28
29    #[error("invalid response: {0}")]
30    InvalidResponse(String),
31
32    #[error("channel error: {0}")]
33    Other(String),
34}
35
36/// Bidirectional communication channel between agent and human reviewer.
37///
38/// Implementations handle delivery (terminal, Slack, email, etc.) and
39/// response collection. The trait is interaction-agnostic — it carries
40/// any TA interaction, not just draft reviews.
41///
42/// # Blocking Semantics
43///
44/// `request_interaction` is blocking: the caller (MCP tool handler) waits
45/// until the human responds. This is the default for v0.4.1.1. Future phases
46/// may add non-blocking modes where the agent continues and checks back later.
47pub trait ReviewChannel: Send + Sync {
48    /// Send an interaction request to the human and await their response.
49    ///
50    /// This is a blocking call — the MCP tool handler suspends until the
51    /// human provides a decision through whatever medium this channel uses.
52    fn request_interaction(
53        &self,
54        request: &InteractionRequest,
55    ) -> Result<InteractionResponse, ReviewChannelError>;
56
57    /// Non-blocking notification to the human.
58    ///
59    /// Used for status updates, progress reports, and informational messages
60    /// that don't require a response.
61    fn notify(&self, notification: &Notification) -> Result<(), ReviewChannelError>;
62
63    /// What this channel supports (async responses, rich media, threads, etc.).
64    fn capabilities(&self) -> ChannelCapabilities;
65
66    /// Channel identity string for audit trail (e.g., "terminal:tty0", "slack:C04ABC").
67    fn channel_id(&self) -> &str;
68}
69
70/// Configuration for selecting and configuring a ReviewChannel.
71#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
72pub struct ReviewChannelConfig {
73    /// Channel type: "terminal" (default), future: "slack", "discord", "email", "webhook".
74    #[serde(default = "default_channel_type")]
75    pub channel_type: String,
76
77    /// Whether the agent blocks on approval (default: true).
78    #[serde(default = "default_true")]
79    pub blocking_mode: bool,
80
81    /// Notification level filter: "debug", "info", "warning", "error".
82    #[serde(default = "default_notification_level")]
83    pub notification_level: String,
84
85    /// Channel-specific configuration (webhook URL, Slack token, etc.).
86    /// Interpretation depends on `channel_type`.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub channel_config: Option<serde_json::Value>,
89}
90
91fn default_channel_type() -> String {
92    "terminal".to_string()
93}
94
95fn default_true() -> bool {
96    true
97}
98
99fn default_notification_level() -> String {
100    "info".to_string()
101}
102
103impl Default for ReviewChannelConfig {
104    fn default() -> Self {
105        Self {
106            channel_type: default_channel_type(),
107            blocking_mode: true,
108            notification_level: default_notification_level(),
109            channel_config: None,
110        }
111    }
112}
113
114/// Construct a ReviewChannel from config (v0.5.3).
115///
116/// This factory function selects the appropriate channel implementation
117/// based on `config.channel_type`. Unknown types return an error.
118pub fn build_channel(
119    config: &ReviewChannelConfig,
120) -> Result<Box<dyn ReviewChannel>, ReviewChannelError> {
121    use crate::terminal_channel::{AutoApproveChannel, TerminalChannel};
122
123    match config.channel_type.as_str() {
124        "terminal" => Ok(Box::new(TerminalChannel::stdio())),
125        "auto-approve" => Ok(Box::new(AutoApproveChannel::new())),
126        "webhook" => {
127            let channel_cfg = config.channel_config.as_ref().ok_or_else(|| {
128                ReviewChannelError::Other(
129                    "webhook channel requires channel_config with 'endpoint' field".into(),
130                )
131            })?;
132            let endpoint = channel_cfg
133                .get("endpoint")
134                .and_then(|v| v.as_str())
135                .ok_or_else(|| {
136                    ReviewChannelError::Other("webhook channel_config missing 'endpoint'".into())
137                })?;
138            Ok(Box::new(crate::webhook_channel::WebhookChannel::new(
139                endpoint,
140            )))
141        }
142        other => Err(ReviewChannelError::Other(format!(
143            "unknown channel type: '{}'. Available: terminal, auto-approve, webhook, discord",
144            other,
145        ))),
146    }
147}
148
149impl fmt::Display for ReviewChannelConfig {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        write!(
152            f,
153            "channel={}, blocking={}, notify_level={}",
154            self.channel_type, self.blocking_mode, self.notification_level
155        )
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn review_channel_config_defaults() {
165        let config = ReviewChannelConfig::default();
166        assert_eq!(config.channel_type, "terminal");
167        assert!(config.blocking_mode);
168        assert_eq!(config.notification_level, "info");
169    }
170
171    #[test]
172    fn review_channel_config_serialization() {
173        let config = ReviewChannelConfig {
174            channel_type: "slack".into(),
175            blocking_mode: false,
176            notification_level: "debug".into(),
177            channel_config: None,
178        };
179        let json = serde_json::to_string(&config).unwrap();
180        let restored: ReviewChannelConfig = serde_json::from_str(&json).unwrap();
181        assert_eq!(restored.channel_type, "slack");
182        assert!(!restored.blocking_mode);
183        assert_eq!(restored.notification_level, "debug");
184    }
185
186    #[test]
187    fn review_channel_config_display() {
188        let config = ReviewChannelConfig::default();
189        let display = format!("{}", config);
190        assert!(display.contains("terminal"));
191        assert!(display.contains("blocking=true"));
192    }
193
194    #[test]
195    fn review_channel_error_display() {
196        let err = ReviewChannelError::ChannelClosed;
197        assert_eq!(format!("{}", err), "channel closed");
198
199        let err = ReviewChannelError::InvalidResponse("bad json".into());
200        assert_eq!(format!("{}", err), "invalid response: bad json");
201    }
202}