agent_tui/ipc/
types.rs

1use serde::Deserialize;
2use serde::Serialize;
3use serde_json::Value;
4use serde_json::json;
5
6use crate::ipc::error_codes;
7
8#[derive(Debug, Deserialize)]
9pub struct RpcRequest {
10    #[allow(dead_code)]
11    jsonrpc: String,
12    pub id: u64,
13    pub method: String,
14    #[serde(default)]
15    pub params: Option<Value>,
16}
17
18impl RpcRequest {
19    /// Create a new RpcRequest.
20    pub fn new(id: u64, method: String, params: Option<Value>) -> Self {
21        Self {
22            jsonrpc: "2.0".to_string(),
23            id,
24            method,
25            params,
26        }
27    }
28
29    pub fn param_str(&self, key: &str) -> Option<&str> {
30        self.params
31            .as_ref()
32            .and_then(|p| p.get(key))
33            .and_then(|v| v.as_str())
34    }
35
36    pub fn param_bool_opt(&self, key: &str) -> Option<bool> {
37        self.params.as_ref()?.get(key)?.as_bool()
38    }
39
40    pub fn param_bool(&self, key: &str, default: bool) -> bool {
41        self.param_bool_opt(key).unwrap_or(default)
42    }
43
44    pub fn param_array(&self, key: &str) -> Option<&Vec<Value>> {
45        self.params.as_ref()?.get(key)?.as_array()
46    }
47
48    pub fn param_u64_opt(&self, key: &str) -> Option<u64> {
49        self.params
50            .as_ref()
51            .and_then(|p| p.get(key))
52            .and_then(|v| v.as_u64())
53    }
54
55    pub fn param_u64(&self, key: &str, default: u64) -> u64 {
56        self.param_u64_opt(key).unwrap_or(default)
57    }
58
59    pub fn param_u16(&self, key: &str, default: u16) -> u16 {
60        self.param_u64(key, default as u64) as u16
61    }
62
63    pub fn param_i32(&self, key: &str, default: i32) -> i32 {
64        self.params
65            .as_ref()
66            .and_then(|p| p.get(key))
67            .and_then(|v| v.as_i64())
68            .map(|n| n as i32)
69            .unwrap_or(default)
70    }
71
72    #[allow(clippy::result_large_err)]
73    pub fn require_str(&self, key: &str) -> Result<&str, RpcResponse> {
74        self.param_str(key)
75            .ok_or_else(|| RpcResponse::error(self.id, -32602, &format!("Missing '{}' param", key)))
76    }
77
78    #[allow(clippy::result_large_err)]
79    pub fn require_array(&self, key: &str) -> Result<&Vec<Value>, RpcResponse> {
80        self.param_array(key)
81            .ok_or_else(|| RpcResponse::error(self.id, -32602, &format!("Missing '{}' param", key)))
82    }
83}
84
85#[derive(Debug, Serialize)]
86pub struct RpcResponse {
87    jsonrpc: String,
88    id: u64,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    result: Option<Value>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    error: Option<RpcServerError>,
93}
94
95#[derive(Debug, Serialize)]
96pub struct RpcServerError {
97    code: i32,
98    message: String,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    data: Option<Value>,
101}
102
103/// Structured error data for programmatic handling by AI agents.
104///
105/// This provides rich context about errors including:
106/// - Category for routing error handling logic
107/// - Retryable flag for automatic retry decisions
108/// - Context with error-specific details
109/// - Suggestion for how to resolve the error
110#[derive(Debug, Serialize)]
111pub struct ErrorData {
112    /// Error category (not_found, invalid_input, busy, internal, external, timeout)
113    pub category: String,
114    /// Whether this error might succeed on retry
115    pub retryable: bool,
116    /// Error-specific context (element_ref, session_id, etc.)
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub context: Option<Value>,
119    /// Human-readable suggestion for resolving the error
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub suggestion: Option<String>,
122}
123
124impl RpcResponse {
125    pub fn success(id: u64, result: Value) -> Self {
126        Self {
127            jsonrpc: "2.0".to_string(),
128            id,
129            result: Some(result),
130            error: None,
131        }
132    }
133
134    pub fn error(id: u64, code: i32, message: &str) -> Self {
135        Self {
136            jsonrpc: "2.0".to_string(),
137            id,
138            result: None,
139            error: Some(RpcServerError {
140                code,
141                message: message.to_string(),
142                data: None,
143            }),
144        }
145    }
146
147    pub fn error_with_context(id: u64, code: i32, message: &str, session_id: Option<&str>) -> Self {
148        let data = session_id.map(|sid| json!({ "session_id": sid }));
149        Self {
150            jsonrpc: "2.0".to_string(),
151            id,
152            result: None,
153            error: Some(RpcServerError {
154                code,
155                message: message.to_string(),
156                data,
157            }),
158        }
159    }
160
161    /// Create an error response with structured ErrorData.
162    ///
163    /// This is the preferred method for domain errors as it provides
164    /// machine-readable context for AI agents.
165    pub fn error_with_data(id: u64, code: i32, message: &str, error_data: ErrorData) -> Self {
166        Self {
167            jsonrpc: "2.0".to_string(),
168            id,
169            result: None,
170            error: Some(RpcServerError {
171                code,
172                message: message.to_string(),
173                data: Some(serde_json::to_value(error_data).unwrap_or(json!({}))),
174            }),
175        }
176    }
177
178    /// Create an error response from a DomainError-like interface.
179    ///
180    /// This helper constructs a fully structured error response with:
181    /// - Semantic error code
182    /// - Human-readable message
183    /// - Category, retryable flag, context, and suggestion
184    pub fn domain_error(
185        id: u64,
186        code: i32,
187        message: &str,
188        category: &str,
189        context: Option<Value>,
190        suggestion: Option<String>,
191    ) -> Self {
192        Self::error_with_data(
193            id,
194            code,
195            message,
196            ErrorData {
197                category: category.to_string(),
198                retryable: error_codes::is_retryable(code),
199                context,
200                suggestion,
201            },
202        )
203    }
204
205    pub fn action_success(id: u64) -> Self {
206        Self::success(id, json!({ "success": true }))
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn make_request(params: Option<Value>) -> RpcRequest {
215        RpcRequest {
216            jsonrpc: "2.0".to_string(),
217            id: 1,
218            method: "test".to_string(),
219            params,
220        }
221    }
222
223    #[test]
224    fn test_param_str_extracts_string() {
225        let req = make_request(Some(json!({"name": "test-value"})));
226        assert_eq!(req.param_str("name"), Some("test-value"));
227    }
228
229    #[test]
230    fn test_param_str_returns_none_for_missing_key() {
231        let req = make_request(Some(json!({"other": "value"})));
232        assert_eq!(req.param_str("name"), None);
233    }
234
235    #[test]
236    fn test_param_bool_opt_extracts_boolean() {
237        let req = make_request(Some(json!({"enabled": true, "disabled": false})));
238        assert_eq!(req.param_bool_opt("enabled"), Some(true));
239        assert_eq!(req.param_bool_opt("disabled"), Some(false));
240    }
241
242    #[test]
243    fn test_param_bool_with_default() {
244        let req = make_request(Some(json!({"enabled": true})));
245        assert!(req.param_bool("enabled", false));
246        assert!(!req.param_bool("missing", false));
247    }
248
249    #[test]
250    fn test_param_array_extracts_array() {
251        let req = make_request(Some(json!({"items": ["a", "b", "c"]})));
252        let arr = req.param_array("items").unwrap();
253        assert_eq!(arr.len(), 3);
254    }
255
256    #[test]
257    fn test_param_u64_extracts_number() {
258        let req = make_request(Some(json!({"timeout": 5000})));
259        assert_eq!(req.param_u64("timeout", 0), 5000);
260    }
261
262    #[test]
263    fn test_param_u64_returns_default_for_missing() {
264        let req = make_request(Some(json!({})));
265        assert_eq!(req.param_u64("timeout", 30000), 30000);
266    }
267
268    #[test]
269    fn test_response_success_format() {
270        let resp = RpcResponse::success(42, json!({"data": "test"}));
271        let json = serde_json::to_string(&resp).unwrap();
272        assert!(json.contains("\"jsonrpc\":\"2.0\""));
273        assert!(json.contains("\"id\":42"));
274        assert!(json.contains("\"result\""));
275        assert!(!json.contains("\"error\""));
276    }
277
278    #[test]
279    fn test_response_error_format() {
280        let resp = RpcResponse::error(99, -32600, "Invalid Request");
281        let json = serde_json::to_string(&resp).unwrap();
282        assert!(json.contains("\"error\""));
283        assert!(json.contains("\"code\":-32600"));
284        assert!(!json.contains("\"result\""));
285    }
286
287    #[test]
288    fn test_action_success_shorthand() {
289        let resp = RpcResponse::action_success(1);
290        let json = serde_json::to_string(&resp).unwrap();
291        assert!(json.contains("\"success\":true"));
292    }
293
294    #[test]
295    fn test_error_with_data_includes_structured_error() {
296        let error_data = ErrorData {
297            category: "not_found".to_string(),
298            retryable: false,
299            context: Some(json!({"element_ref": "@btn1"})),
300            suggestion: Some("Run 'snapshot -i' to see elements.".to_string()),
301        };
302        let resp = RpcResponse::error_with_data(42, -32003, "Element not found", error_data);
303        let json_str = serde_json::to_string(&resp).unwrap();
304        let parsed: Value = serde_json::from_str(&json_str).unwrap();
305
306        assert_eq!(parsed["error"]["code"], -32003);
307        assert_eq!(parsed["error"]["data"]["category"], "not_found");
308        assert_eq!(parsed["error"]["data"]["retryable"], false);
309        assert_eq!(parsed["error"]["data"]["context"]["element_ref"], "@btn1");
310    }
311
312    #[test]
313    fn test_domain_error_sets_retryable_for_lock_timeout() {
314        let resp = RpcResponse::domain_error(
315            1,
316            -32007, // LOCK_TIMEOUT
317            "Lock timeout",
318            "busy",
319            None,
320            Some("Try again".to_string()),
321        );
322        let json_str = serde_json::to_string(&resp).unwrap();
323        let parsed: Value = serde_json::from_str(&json_str).unwrap();
324
325        assert_eq!(parsed["error"]["data"]["retryable"], true);
326    }
327
328    #[test]
329    fn test_domain_error_not_retryable_for_element_not_found() {
330        let resp = RpcResponse::domain_error(
331            1,
332            -32003, // ELEMENT_NOT_FOUND
333            "Element not found",
334            "not_found",
335            Some(json!({"element_ref": "@btn1"})),
336            None,
337        );
338        let json_str = serde_json::to_string(&resp).unwrap();
339        let parsed: Value = serde_json::from_str(&json_str).unwrap();
340
341        assert_eq!(parsed["error"]["data"]["retryable"], false);
342    }
343}