agent_tui/terminal/
error.rs

1//! PTY errors with structured context for AI agents.
2//!
3//! These errors provide semantic codes, categories, and actionable suggestions
4//! to enable programmatic error handling.
5
6use crate::common::error_codes::{self, ErrorCategory};
7use serde_json::{Value, json};
8use thiserror::Error;
9
10/// PTY operation errors with structured context.
11#[derive(Error, Debug)]
12pub enum PtyError {
13    #[error("Failed to open PTY: {0}")]
14    Open(String),
15    #[error("Failed to spawn process: {0}")]
16    Spawn(String),
17    #[error("Failed to write to PTY: {0}")]
18    Write(String),
19    #[error("Failed to read from PTY: {0}")]
20    Read(String),
21    #[error("Failed to resize PTY: {0}")]
22    Resize(String),
23}
24
25impl PtyError {
26    /// Returns the JSON-RPC error code for this error.
27    ///
28    /// All PTY errors map to PTY_ERROR (-32008) since they're all
29    /// external/terminal communication failures. The specific operation
30    /// is available via `context()`.
31    pub fn code(&self) -> i32 {
32        error_codes::PTY_ERROR
33    }
34
35    /// Returns the error category for programmatic handling.
36    pub fn category(&self) -> ErrorCategory {
37        ErrorCategory::External
38    }
39
40    /// Returns structured context about the error for debugging.
41    pub fn context(&self) -> Value {
42        match self {
43            PtyError::Open(reason) => json!({
44                "operation": "open",
45                "reason": reason
46            }),
47            PtyError::Spawn(reason) => json!({
48                "operation": "spawn",
49                "reason": reason
50            }),
51            PtyError::Write(reason) => json!({
52                "operation": "write",
53                "reason": reason
54            }),
55            PtyError::Read(reason) => json!({
56                "operation": "read",
57                "reason": reason
58            }),
59            PtyError::Resize(reason) => json!({
60                "operation": "resize",
61                "reason": reason
62            }),
63        }
64    }
65
66    /// Returns a helpful suggestion for resolving the error.
67    pub fn suggestion(&self) -> String {
68        match self {
69            PtyError::Open(_) => {
70                "PTY allocation failed. Check system resource limits (ulimit -n) or try restarting."
71                    .to_string()
72            }
73            PtyError::Spawn(reason) => {
74                if reason.contains("not found") || reason.contains("No such file") {
75                    "Command not found. Check if the command exists and is in PATH.".to_string()
76                } else if reason.contains("Permission denied") {
77                    "Permission denied. Check file permissions.".to_string()
78                } else {
79                    "Process spawn failed. Check command syntax and permissions.".to_string()
80                }
81            }
82            PtyError::Write(_) => {
83                "Failed to send input to terminal. The session may have ended. Run 'sessions' to check status."
84                    .to_string()
85            }
86            PtyError::Read(_) => {
87                "Failed to read terminal output. The session may have ended. Run 'sessions' to check status."
88                    .to_string()
89            }
90            PtyError::Resize(_) => {
91                "Failed to resize terminal. Try again or restart the session.".to_string()
92            }
93        }
94    }
95
96    /// Returns whether this error is potentially transient and may succeed on retry.
97    pub fn is_retryable(&self) -> bool {
98        matches!(self, PtyError::Read(_) | PtyError::Write(_))
99    }
100
101    /// Returns the operation that failed.
102    pub fn operation(&self) -> &'static str {
103        match self {
104            PtyError::Open(_) => "open",
105            PtyError::Spawn(_) => "spawn",
106            PtyError::Write(_) => "write",
107            PtyError::Read(_) => "read",
108            PtyError::Resize(_) => "resize",
109        }
110    }
111
112    /// Returns the underlying reason/message for the error.
113    pub fn reason(&self) -> &str {
114        match self {
115            PtyError::Open(r)
116            | PtyError::Spawn(r)
117            | PtyError::Write(r)
118            | PtyError::Read(r)
119            | PtyError::Resize(r) => r,
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn test_pty_error_code() {
130        let err = PtyError::Open("test".into());
131        assert_eq!(err.code(), error_codes::PTY_ERROR);
132    }
133
134    #[test]
135    fn test_pty_error_category() {
136        let err = PtyError::Write("broken pipe".into());
137        assert_eq!(err.category(), ErrorCategory::External);
138    }
139
140    #[test]
141    fn test_pty_error_context() {
142        let err = PtyError::Spawn("command not found".into());
143        let ctx = err.context();
144        assert_eq!(ctx["operation"], "spawn");
145        assert_eq!(ctx["reason"], "command not found");
146    }
147
148    #[test]
149    fn test_pty_error_suggestion_not_found() {
150        let err = PtyError::Spawn("No such file or directory".into());
151        assert!(err.suggestion().contains("not found"));
152    }
153
154    #[test]
155    fn test_pty_error_suggestion_permission() {
156        let err = PtyError::Spawn("Permission denied".into());
157        assert!(err.suggestion().contains("Permission"));
158    }
159
160    #[test]
161    fn test_pty_error_is_retryable() {
162        assert!(PtyError::Read("timeout".into()).is_retryable());
163        assert!(PtyError::Write("broken pipe".into()).is_retryable());
164        assert!(!PtyError::Open("failed".into()).is_retryable());
165        assert!(!PtyError::Spawn("not found".into()).is_retryable());
166    }
167
168    #[test]
169    fn test_pty_error_operation() {
170        assert_eq!(PtyError::Open("x".into()).operation(), "open");
171        assert_eq!(PtyError::Spawn("x".into()).operation(), "spawn");
172        assert_eq!(PtyError::Write("x".into()).operation(), "write");
173        assert_eq!(PtyError::Read("x".into()).operation(), "read");
174        assert_eq!(PtyError::Resize("x".into()).operation(), "resize");
175    }
176
177    #[test]
178    fn test_pty_error_reason() {
179        let err = PtyError::Open("allocation failed".into());
180        assert_eq!(err.reason(), "allocation failed");
181    }
182}