eoka_protocol/
response.rs1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
4pub struct ErrorDetail {
5 pub code: String,
6 pub message: String,
7 #[serde(skip_serializing_if = "Option::is_none")]
8 pub retryable: Option<bool>,
9 #[serde(skip_serializing_if = "Option::is_none")]
10 pub hint: Option<String>,
11 #[serde(skip_serializing_if = "Option::is_none")]
12 pub context: Option<Box<crate::ErrorContext>>,
13}
14
15impl ErrorDetail {
16 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
17 Self {
18 code: code.into(),
19 message: message.into(),
20 retryable: None,
21 hint: None,
22 context: None,
23 }
24 }
25
26 pub fn retryable(mut self, retryable: bool) -> Self {
27 self.retryable = Some(retryable);
28 self
29 }
30
31 pub fn hint(mut self, hint: impl Into<String>) -> Self {
32 self.hint = Some(hint.into());
33 self
34 }
35
36 pub fn context(mut self, context: crate::ErrorContext) -> Self {
37 self.context = Some(Box::new(context));
38 self
39 }
40}
41
42#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
43pub struct ResponseMeta {
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub session: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub cmd: Option<String>,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub socket: Option<String>,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub log: Option<String>,
52}
53
54impl ResponseMeta {
55 pub fn is_empty(&self) -> bool {
56 self.session.is_none() && self.cmd.is_none() && self.socket.is_none() && self.log.is_none()
57 }
58}
59
60#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
61pub struct Response {
62 pub ok: bool,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub data: Option<serde_json::Value>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub error: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub error_detail: Option<ErrorDetail>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub meta: Option<ResponseMeta>,
71}
72
73impl Response {
74 pub fn ok(data: impl Into<serde_json::Value>) -> Self {
75 Self {
76 ok: true,
77 data: Some(data.into()),
78 error: None,
79 error_detail: None,
80 meta: None,
81 }
82 }
83
84 pub fn ok_text(msg: impl Into<String>) -> Self {
85 Self::ok(serde_json::Value::String(msg.into()))
86 }
87
88 pub fn err(msg: impl Into<String>) -> Self {
89 let msg = msg.into();
90 Self {
91 ok: false,
92 data: None,
93 error: Some(msg.clone()),
94 error_detail: Some(classify_error(&msg)),
95 meta: None,
96 }
97 }
98
99 pub fn err_detail(detail: ErrorDetail) -> Self {
100 Self {
101 ok: false,
102 data: None,
103 error: Some(detail.message.clone()),
104 error_detail: Some(detail),
105 meta: None,
106 }
107 }
108
109 pub fn with_meta(mut self, meta: ResponseMeta) -> Self {
110 if !meta.is_empty() {
111 self.meta = Some(meta);
112 }
113 self
114 }
115}
116
117fn classify_error(message: &str) -> ErrorDetail {
118 let lower = message.to_ascii_lowercase();
119 if lower.contains("no browser open") {
120 return ErrorDetail::new("no_browser_open", message)
121 .retryable(true)
122 .hint("Run eoka open before browser observation or interaction commands.");
123 }
124 if lower.contains("unknown") || lower.contains("invalid") {
125 return ErrorDetail::new("invalid_input", message).retryable(false);
126 }
127 if lower.contains("daemon failed to start") || lower.contains("transport") {
128 return ErrorDetail::new("transport_error", message).retryable(true);
129 }
130 ErrorDetail::new("eoka_error", message)
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn error_context_is_additive_and_round_trips_without_changing_legacy_data() {
139 let old: Response = serde_json::from_value(serde_json::json!({
140 "ok":false,"error":"missing", "error_detail":{"code":"missing","message":"missing"}
141 }))
142 .unwrap();
143 assert!(old.error_detail.unwrap().context.is_none());
144 let response = Response::err_detail(
145 ErrorDetail::new("download_wait_timeout", "Still running").context(
146 crate::ErrorContext {
147 download_id: Some("job".into()),
148 action_status: Some(crate::ActionStatus::MayHaveExecuted),
149 ..Default::default()
150 },
151 ),
152 );
153 let value = serde_json::to_value(&response).unwrap();
154 assert_eq!(value["error"], "Still running");
155 assert_eq!(
156 value["error_detail"]["context"]["action_status"],
157 "may_have_executed"
158 );
159 let restored: Response = serde_json::from_value(value).unwrap();
160 assert_eq!(
161 restored
162 .error_detail
163 .unwrap()
164 .context
165 .unwrap()
166 .download_id
167 .as_deref(),
168 Some("job")
169 );
170 }
171
172 #[test]
173 fn structured_error_keeps_legacy_error_string() {
174 let response = Response::err("No browser open. Use 'open' first.");
175
176 assert_eq!(
177 response.error.as_deref(),
178 Some("No browser open. Use 'open' first.")
179 );
180 let detail = response.error_detail.unwrap();
181 assert_eq!(detail.code, "no_browser_open");
182 assert_eq!(detail.retryable, Some(true));
183 assert!(detail.hint.unwrap().contains("eoka open"));
184 }
185
186 #[test]
187 fn empty_meta_is_not_serialized() {
188 let value =
189 serde_json::to_value(Response::ok("ok").with_meta(ResponseMeta::default())).unwrap();
190
191 assert!(value.get("meta").is_none());
192 }
193}