Skip to main content

ta_changeset/
terminal_channel.rs

1// terminal_channel.rs — Terminal-based ReviewChannel adapter.
2//
3// The default ReviewChannel implementation for v0.4.1.1. Renders interaction
4// requests to stdout with formatting, collects responses from stdin.
5// Supports mock I/O for testing.
6
7use std::io::{BufRead, BufReader, Read, Write};
8use std::sync::Mutex;
9
10use crate::interaction::{
11    ChannelCapabilities, Decision, InteractionKind, InteractionRequest, InteractionResponse,
12    Notification, NotificationLevel,
13};
14use crate::review_channel::{ReviewChannel, ReviewChannelError};
15use crate::session_channel::{HumanInput, SessionChannel, SessionChannelError, SessionEvent};
16
17/// A ReviewChannel that uses stdin/stdout for human interaction.
18///
19/// Renders interaction requests as formatted text, prompts for input,
20/// and parses responses into InteractionResponse values.
21pub struct TerminalChannel {
22    reader: Mutex<BufReader<Box<dyn Read + Send>>>,
23    writer: Mutex<Box<dyn Write + Send>>,
24    channel_id: String,
25}
26
27impl TerminalChannel {
28    /// Create a TerminalChannel from raw reader/writer.
29    /// Use `TerminalChannel::stdio()` for real terminal, or pass mock I/O for tests.
30    pub fn new(
31        reader: Box<dyn Read + Send>,
32        writer: Box<dyn Write + Send>,
33        channel_id: impl Into<String>,
34    ) -> Self {
35        Self {
36            reader: Mutex::new(BufReader::new(reader)),
37            writer: Mutex::new(writer),
38            channel_id: channel_id.into(),
39        }
40    }
41
42    /// Create a TerminalChannel that reads/writes to real stdin/stdout.
43    pub fn stdio() -> Self {
44        Self::new(
45            Box::new(std::io::stdin()),
46            Box::new(std::io::stdout()),
47            "terminal:stdio",
48        )
49    }
50
51    /// Render an interaction request as formatted text.
52    fn render_request(&self, request: &InteractionRequest) -> String {
53        let mut out = String::new();
54        out.push('\n');
55        out.push_str(&"=".repeat(60));
56        out.push('\n');
57
58        match &request.kind {
59            InteractionKind::DraftReview => {
60                out.push_str("  DRAFT REVIEW REQUIRED\n");
61                out.push_str(&"-".repeat(60));
62                out.push('\n');
63                if let Some(summary) = request.context.get("summary").and_then(|v| v.as_str()) {
64                    out.push_str(&format!("  Summary: {}\n", summary));
65                }
66                if let Some(count) = request
67                    .context
68                    .get("artifact_count")
69                    .and_then(|v| v.as_u64())
70                {
71                    out.push_str(&format!("  Artifacts: {}\n", count));
72                }
73                if let Some(draft_id) = request.context.get("draft_id").and_then(|v| v.as_str()) {
74                    out.push_str(&format!("  Draft ID: {}\n", draft_id));
75                }
76            }
77            InteractionKind::PlanNegotiation => {
78                out.push_str("  PLAN UPDATE PROPOSED\n");
79                out.push_str(&"-".repeat(60));
80                out.push('\n');
81                if let Some(phase) = request.context.get("phase").and_then(|v| v.as_str()) {
82                    out.push_str(&format!("  Phase: {}\n", phase));
83                }
84                if let Some(status) = request
85                    .context
86                    .get("proposed_status")
87                    .and_then(|v| v.as_str())
88                {
89                    out.push_str(&format!("  Proposed status: {}\n", status));
90                }
91            }
92            InteractionKind::ApprovalDiscussion => {
93                out.push_str("  APPROVAL REQUIRED\n");
94                out.push_str(&"-".repeat(60));
95                out.push('\n');
96                if let Some(msg) = request.context.as_str() {
97                    out.push_str(&format!("  {}\n", msg));
98                }
99            }
100            InteractionKind::Escalation => {
101                out.push_str("  ESCALATION\n");
102                out.push_str(&"-".repeat(60));
103                out.push('\n');
104                if let Some(reason) = request.context.get("reason").and_then(|v| v.as_str()) {
105                    out.push_str(&format!("  Reason: {}\n", reason));
106                }
107            }
108            InteractionKind::AgentQuestion => {
109                out.push_str("  AGENT QUESTION\n");
110                out.push_str(&"-".repeat(60));
111                out.push('\n');
112                if let Some(q) = request.context.get("question").and_then(|v| v.as_str()) {
113                    out.push_str(&format!("  Question: {}\n", q));
114                }
115                if let Some(ctx) = request.context.get("context").and_then(|v| v.as_str()) {
116                    out.push_str(&format!("  Context: {}\n", ctx));
117                }
118                if let Some(hint) = request
119                    .context
120                    .get("response_hint")
121                    .and_then(|v| v.as_str())
122                {
123                    out.push_str(&format!("  Expected response: {}\n", hint));
124                }
125            }
126            InteractionKind::Custom(name) => {
127                out.push_str(&format!("  INTERACTION: {}\n", name.to_uppercase()));
128                out.push_str(&"-".repeat(60));
129                out.push('\n');
130            }
131        }
132
133        out.push_str(&"-".repeat(60));
134        out.push('\n');
135        out.push_str("  [a]pprove  [r]eject  [d]iscuss  [s]kip\n");
136        out.push_str(&"=".repeat(60));
137        out.push_str("\n> ");
138        out
139    }
140
141    /// Parse a user's text response into a Decision.
142    fn parse_decision(input: &str) -> Result<Decision, ReviewChannelError> {
143        let trimmed = input.trim().to_lowercase();
144        match trimmed.as_str() {
145            "a" | "approve" | "y" | "yes" => Ok(Decision::Approve),
146            "d" | "discuss" => Ok(Decision::Discuss),
147            "s" | "skip" => Ok(Decision::SkipForNow),
148            _ if trimmed.starts_with("r") || trimmed.starts_with("n") => {
149                // "r", "reject", "n", "no" — optionally followed by a reason
150                let reason = if trimmed.len() > 1 {
151                    // "reject: reason" or "r reason" or "r: reason"
152                    let rest = trimmed
153                        .trim_start_matches("reject")
154                        .trim_start_matches("no")
155                        .trim_start_matches('r')
156                        .trim_start_matches('n')
157                        .trim_start_matches(':')
158                        .trim();
159                    if rest.is_empty() {
160                        "rejected by reviewer".to_string()
161                    } else {
162                        rest.to_string()
163                    }
164                } else {
165                    "rejected by reviewer".to_string()
166                };
167                Ok(Decision::Reject { reason })
168            }
169            "" => Err(ReviewChannelError::InvalidResponse("empty response".into())),
170            _ => Err(ReviewChannelError::InvalidResponse(format!(
171                "unrecognized input: '{}'",
172                trimmed
173            ))),
174        }
175    }
176
177    /// Render a notification as formatted text.
178    fn render_notification(notification: &Notification) -> String {
179        let prefix = match notification.level {
180            NotificationLevel::Debug => "[DEBUG]",
181            NotificationLevel::Info => "[INFO]",
182            NotificationLevel::Warning => "[WARN]",
183            NotificationLevel::Error => "[ERROR]",
184        };
185        format!("{} {}\n", prefix, notification.message)
186    }
187}
188
189impl ReviewChannel for TerminalChannel {
190    fn request_interaction(
191        &self,
192        request: &InteractionRequest,
193    ) -> Result<InteractionResponse, ReviewChannelError> {
194        let rendered = self.render_request(request);
195
196        // Write the rendered request to output.
197        {
198            let mut writer = self
199                .writer
200                .lock()
201                .map_err(|e| ReviewChannelError::Other(format!("writer lock poisoned: {}", e)))?;
202            writer.write_all(rendered.as_bytes())?;
203            writer.flush()?;
204        }
205
206        // Read the response from input.
207        let mut line = String::new();
208        {
209            let mut reader = self
210                .reader
211                .lock()
212                .map_err(|e| ReviewChannelError::Other(format!("reader lock poisoned: {}", e)))?;
213            let bytes = reader.read_line(&mut line)?;
214            if bytes == 0 {
215                return Err(ReviewChannelError::ChannelClosed);
216            }
217        }
218
219        let decision = Self::parse_decision(&line)?;
220
221        Ok(InteractionResponse::new(request.interaction_id, decision)
222            .with_responder(&self.channel_id))
223    }
224
225    fn notify(&self, notification: &Notification) -> Result<(), ReviewChannelError> {
226        let rendered = Self::render_notification(notification);
227        let mut writer = self
228            .writer
229            .lock()
230            .map_err(|e| ReviewChannelError::Other(format!("writer lock poisoned: {}", e)))?;
231        writer.write_all(rendered.as_bytes())?;
232        writer.flush()?;
233        Ok(())
234    }
235
236    fn capabilities(&self) -> ChannelCapabilities {
237        ChannelCapabilities {
238            supports_async: false,
239            supports_rich_media: false,
240            supports_threads: false,
241        }
242    }
243
244    fn channel_id(&self) -> &str {
245        &self.channel_id
246    }
247}
248
249/// A no-op ReviewChannel that auto-approves all interactions.
250/// Useful for non-interactive/batch mode and testing.
251pub struct AutoApproveChannel {
252    channel_id: String,
253}
254
255impl AutoApproveChannel {
256    pub fn new() -> Self {
257        Self {
258            channel_id: "auto-approve".to_string(),
259        }
260    }
261}
262
263impl Default for AutoApproveChannel {
264    fn default() -> Self {
265        Self::new()
266    }
267}
268
269/// A SessionChannel implementation for terminal-based interaction (v0.7.0).
270///
271/// Emits session events to stdout and reads human input from stdin.
272pub struct TerminalSessionChannel {
273    channel_id: String,
274}
275
276impl TerminalSessionChannel {
277    pub fn new() -> Self {
278        Self {
279            channel_id: "terminal:session".to_string(),
280        }
281    }
282}
283
284impl Default for TerminalSessionChannel {
285    fn default() -> Self {
286        Self::new()
287    }
288}
289
290impl SessionChannel for TerminalSessionChannel {
291    fn emit(&self, event: &SessionEvent) -> Result<(), SessionChannelError> {
292        println!("{}", event);
293        Ok(())
294    }
295
296    fn receive(
297        &self,
298        timeout: std::time::Duration,
299    ) -> Result<Option<HumanInput>, SessionChannelError> {
300        // Simple blocking stdin read with no real timeout (terminal limitation).
301        // In practice, the agent process drives the session and the human types
302        // when prompted. A full PTY-based implementation would use non-blocking I/O.
303        let _ = timeout;
304        let mut line = String::new();
305        match std::io::stdin().read_line(&mut line) {
306            Ok(0) => Ok(None), // EOF
307            Ok(_) => {
308                let trimmed = line.trim();
309                if trimmed.is_empty() {
310                    Ok(None)
311                } else if trimmed.eq_ignore_ascii_case("abort") {
312                    Ok(Some(HumanInput::Abort))
313                } else {
314                    Ok(Some(HumanInput::Message {
315                        text: trimmed.to_string(),
316                    }))
317                }
318            }
319            Err(e) => Err(SessionChannelError::Io(e)),
320        }
321    }
322
323    fn channel_id(&self) -> &str {
324        &self.channel_id
325    }
326}
327
328impl ReviewChannel for AutoApproveChannel {
329    fn request_interaction(
330        &self,
331        request: &InteractionRequest,
332    ) -> Result<InteractionResponse, ReviewChannelError> {
333        Ok(
334            InteractionResponse::new(request.interaction_id, Decision::Approve)
335                .with_responder(&self.channel_id),
336        )
337    }
338
339    fn notify(&self, _notification: &Notification) -> Result<(), ReviewChannelError> {
340        Ok(())
341    }
342
343    fn capabilities(&self) -> ChannelCapabilities {
344        ChannelCapabilities::default()
345    }
346
347    fn channel_id(&self) -> &str {
348        &self.channel_id
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use crate::interaction::Notification;
356    use std::io::Cursor;
357    use uuid::Uuid;
358
359    fn mock_channel(input: &str) -> (TerminalChannel, std::sync::Arc<Mutex<Vec<u8>>>) {
360        let output_buf = std::sync::Arc::new(Mutex::new(Vec::new()));
361        let output_writer = output_buf.clone();
362
363        struct SharedWriter(std::sync::Arc<Mutex<Vec<u8>>>);
364        impl Write for SharedWriter {
365            fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
366                self.0.lock().unwrap().write(buf)
367            }
368            fn flush(&mut self) -> std::io::Result<()> {
369                Ok(())
370            }
371        }
372
373        let reader = Box::new(Cursor::new(input.as_bytes().to_vec()));
374        let writer = Box::new(SharedWriter(output_writer));
375        let channel = TerminalChannel::new(reader, writer, "test:mock");
376        (channel, output_buf)
377    }
378
379    #[test]
380    fn approve_draft_review() {
381        let (channel, _output) = mock_channel("a\n");
382        let req = InteractionRequest::draft_review(Uuid::new_v4(), "Test draft", 3);
383        let resp = channel.request_interaction(&req).unwrap();
384        assert_eq!(resp.decision, Decision::Approve);
385        assert_eq!(resp.interaction_id, req.interaction_id);
386        assert_eq!(resp.responder_id.as_deref(), Some("test:mock"));
387    }
388
389    #[test]
390    fn reject_with_reason() {
391        let (channel, _output) = mock_channel("reject: needs more tests\n");
392        let req = InteractionRequest::draft_review(Uuid::new_v4(), "Draft", 1);
393        let resp = channel.request_interaction(&req).unwrap();
394        assert_eq!(
395            resp.decision,
396            Decision::Reject {
397                reason: "needs more tests".into()
398            }
399        );
400    }
401
402    #[test]
403    fn reject_shorthand() {
404        let (channel, _output) = mock_channel("r\n");
405        let req = InteractionRequest::draft_review(Uuid::new_v4(), "Draft", 1);
406        let resp = channel.request_interaction(&req).unwrap();
407        assert!(matches!(resp.decision, Decision::Reject { .. }));
408    }
409
410    #[test]
411    fn discuss_response() {
412        let (channel, _output) = mock_channel("d\n");
413        let req = InteractionRequest::draft_review(Uuid::new_v4(), "Draft", 1);
414        let resp = channel.request_interaction(&req).unwrap();
415        assert_eq!(resp.decision, Decision::Discuss);
416    }
417
418    #[test]
419    fn skip_response() {
420        let (channel, _output) = mock_channel("s\n");
421        let req = InteractionRequest::draft_review(Uuid::new_v4(), "Draft", 1);
422        let resp = channel.request_interaction(&req).unwrap();
423        assert_eq!(resp.decision, Decision::SkipForNow);
424    }
425
426    #[test]
427    fn yes_is_approve() {
428        let (channel, _output) = mock_channel("yes\n");
429        let req = InteractionRequest::draft_review(Uuid::new_v4(), "Draft", 1);
430        let resp = channel.request_interaction(&req).unwrap();
431        assert_eq!(resp.decision, Decision::Approve);
432    }
433
434    #[test]
435    fn empty_input_is_error() {
436        let (channel, _output) = mock_channel("\n");
437        let req = InteractionRequest::draft_review(Uuid::new_v4(), "Draft", 1);
438        let result = channel.request_interaction(&req);
439        assert!(matches!(
440            result,
441            Err(ReviewChannelError::InvalidResponse(_))
442        ));
443    }
444
445    #[test]
446    fn eof_is_channel_closed() {
447        let (channel, _output) = mock_channel("");
448        let req = InteractionRequest::draft_review(Uuid::new_v4(), "Draft", 1);
449        let result = channel.request_interaction(&req);
450        assert!(matches!(result, Err(ReviewChannelError::ChannelClosed)));
451    }
452
453    #[test]
454    fn renders_draft_review_output() {
455        let (channel, output) = mock_channel("a\n");
456        let req = InteractionRequest::draft_review(Uuid::new_v4(), "Add auth module", 5);
457        channel.request_interaction(&req).unwrap();
458
459        let rendered = String::from_utf8(output.lock().unwrap().clone()).unwrap();
460        assert!(rendered.contains("DRAFT REVIEW REQUIRED"));
461        assert!(rendered.contains("Add auth module"));
462        assert!(rendered.contains("Artifacts: 5"));
463        assert!(rendered.contains("[a]pprove"));
464    }
465
466    #[test]
467    fn renders_plan_negotiation() {
468        let (channel, output) = mock_channel("a\n");
469        let req = InteractionRequest::plan_negotiation("v0.4.2", "done");
470        channel.request_interaction(&req).unwrap();
471
472        let rendered = String::from_utf8(output.lock().unwrap().clone()).unwrap();
473        assert!(rendered.contains("PLAN UPDATE PROPOSED"));
474        assert!(rendered.contains("v0.4.2"));
475    }
476
477    #[test]
478    fn notify_renders_to_output() {
479        let (channel, output) = mock_channel("");
480        let notif = Notification::info("Sub-goal 2 of 5 complete");
481        channel.notify(&notif).unwrap();
482
483        let rendered = String::from_utf8(output.lock().unwrap().clone()).unwrap();
484        assert!(rendered.contains("[INFO]"));
485        assert!(rendered.contains("Sub-goal 2 of 5 complete"));
486    }
487
488    #[test]
489    fn notify_warning_prefix() {
490        let (channel, output) = mock_channel("");
491        let notif = Notification::warning("Agent approaching token limit");
492        channel.notify(&notif).unwrap();
493
494        let rendered = String::from_utf8(output.lock().unwrap().clone()).unwrap();
495        assert!(rendered.contains("[WARN]"));
496    }
497
498    #[test]
499    fn channel_capabilities() {
500        let (channel, _) = mock_channel("");
501        let caps = channel.capabilities();
502        assert!(!caps.supports_async);
503        assert!(!caps.supports_rich_media);
504        assert!(!caps.supports_threads);
505    }
506
507    #[test]
508    fn channel_id_returns_configured_id() {
509        let (channel, _) = mock_channel("");
510        assert_eq!(channel.channel_id(), "test:mock");
511    }
512
513    #[test]
514    fn auto_approve_channel_approves_all() {
515        let channel = AutoApproveChannel::new();
516        let req = InteractionRequest::draft_review(Uuid::new_v4(), "Any draft", 10);
517        let resp = channel.request_interaction(&req).unwrap();
518        assert_eq!(resp.decision, Decision::Approve);
519        assert_eq!(resp.responder_id.as_deref(), Some("auto-approve"));
520    }
521
522    #[test]
523    fn auto_approve_channel_notify_is_noop() {
524        let channel = AutoApproveChannel::new();
525        let notif = Notification::info("test");
526        assert!(channel.notify(&notif).is_ok());
527    }
528
529    #[test]
530    fn parse_decision_variants() {
531        assert_eq!(
532            TerminalChannel::parse_decision("approve").unwrap(),
533            Decision::Approve
534        );
535        assert_eq!(
536            TerminalChannel::parse_decision("y").unwrap(),
537            Decision::Approve
538        );
539        assert_eq!(
540            TerminalChannel::parse_decision("discuss").unwrap(),
541            Decision::Discuss
542        );
543        assert_eq!(
544            TerminalChannel::parse_decision("skip").unwrap(),
545            Decision::SkipForNow
546        );
547        assert!(TerminalChannel::parse_decision("unknown").is_err());
548    }
549}