agent_engine/extensions/
commands.rs1use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23#[serde(tag = "kind", rename_all = "snake_case")]
24pub enum CommandOutputEvent {
25 Text { content: String },
27 System { content: String },
29 Error { content: String },
31 Table {
33 headers: Vec<String>,
34 rows: Vec<Vec<String>>,
35 },
36 Done,
38}
39
40#[derive(Debug, Clone, PartialEq)]
42pub struct CommandOutputFrame {
43 pub request_id: String,
44 pub event: CommandOutputEvent,
45}
46
47pub fn parse_command_output(params: &Value) -> Result<CommandOutputFrame, String> {
52 let obj = params
53 .as_object()
54 .ok_or_else(|| "command.output params must be a JSON object".to_string())?;
55
56 let request_id = obj
57 .get("request_id")
58 .and_then(Value::as_str)
59 .ok_or_else(|| "command.output missing request_id".to_string())?
60 .to_string();
61 if request_id.is_empty() {
62 return Err("command.output request_id must be non-empty".to_string());
63 }
64
65 let event_value: Value = match obj.get("event") {
66 Some(v) => v.clone(),
67 None => {
68 let mut clone = obj.clone();
70 clone.remove("request_id");
71 Value::Object(clone)
72 }
73 };
74
75 let event = parse_command_output_event(&event_value)?;
76 Ok(CommandOutputFrame { request_id, event })
77}
78
79pub fn parse_command_output_event(event: &Value) -> Result<CommandOutputEvent, String> {
81 let obj = event
82 .as_object()
83 .ok_or_else(|| "command.output event must be a JSON object".to_string())?;
84 let kind = obj
85 .get("kind")
86 .and_then(Value::as_str)
87 .ok_or_else(|| "command.output event missing 'kind'".to_string())?;
88
89 match kind {
90 "text" => {
91 let content = obj
92 .get("content")
93 .and_then(Value::as_str)
94 .ok_or_else(|| "command.output text event missing 'content'".to_string())?
95 .to_string();
96 Ok(CommandOutputEvent::Text { content })
97 }
98 "system" => {
99 let content = obj
100 .get("content")
101 .and_then(Value::as_str)
102 .ok_or_else(|| "command.output system event missing 'content'".to_string())?
103 .to_string();
104 Ok(CommandOutputEvent::System { content })
105 }
106 "error" => {
107 let content = obj
108 .get("content")
109 .and_then(Value::as_str)
110 .ok_or_else(|| "command.output error event missing 'content'".to_string())?
111 .to_string();
112 if content.is_empty() {
113 return Err("command.output error content must be non-empty".to_string());
114 }
115 Ok(CommandOutputEvent::Error { content })
116 }
117 "table" => {
118 let headers = obj
119 .get("headers")
120 .and_then(Value::as_array)
121 .ok_or_else(|| "command.output table missing 'headers' array".to_string())?
122 .iter()
123 .map(|v| {
124 v.as_str()
125 .map(str::to_string)
126 .ok_or_else(|| "command.output table header must be string".to_string())
127 })
128 .collect::<Result<Vec<_>, _>>()?;
129 let rows = obj
130 .get("rows")
131 .and_then(Value::as_array)
132 .ok_or_else(|| "command.output table missing 'rows' array".to_string())?
133 .iter()
134 .map(|row| {
135 row.as_array()
136 .ok_or_else(|| "command.output table row must be array".to_string())?
137 .iter()
138 .map(|cell| {
139 cell.as_str().map(str::to_string).ok_or_else(|| {
140 "command.output table cell must be string".to_string()
141 })
142 })
143 .collect::<Result<Vec<_>, _>>()
144 })
145 .collect::<Result<Vec<_>, _>>()?;
146 Ok(CommandOutputEvent::Table { headers, rows })
147 }
148 "done" => Ok(CommandOutputEvent::Done),
149 other => Err(format!("unknown command.output event kind: {other}")),
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use serde_json::json;
157
158 #[test]
159 fn parses_text_event_nested() {
160 let v = json!({"request_id": "r1", "event": {"kind": "text", "content": "hello"}});
161 let frame = parse_command_output(&v).unwrap();
162 assert_eq!(frame.request_id, "r1");
163 assert_eq!(
164 frame.event,
165 CommandOutputEvent::Text {
166 content: "hello".into()
167 }
168 );
169 }
170
171 #[test]
172 fn parses_text_event_flat() {
173 let v = json!({"request_id": "r2", "kind": "text", "content": "hi"});
174 let frame = parse_command_output(&v).unwrap();
175 assert_eq!(frame.request_id, "r2");
176 assert_eq!(
177 frame.event,
178 CommandOutputEvent::Text {
179 content: "hi".into()
180 }
181 );
182 }
183
184 #[test]
185 fn parses_system_error_done() {
186 let frame = parse_command_output(
187 &json!({"request_id":"x","event":{"kind":"system","content":"sys"}}),
188 )
189 .unwrap();
190 assert!(matches!(frame.event, CommandOutputEvent::System { .. }));
191
192 let frame = parse_command_output(
193 &json!({"request_id":"x","event":{"kind":"error","content":"oops"}}),
194 )
195 .unwrap();
196 assert!(matches!(frame.event, CommandOutputEvent::Error { .. }));
197
198 let frame =
199 parse_command_output(&json!({"request_id":"x","event":{"kind":"done"}})).unwrap();
200 assert_eq!(frame.event, CommandOutputEvent::Done);
201 }
202
203 #[test]
204 fn parses_table() {
205 let v = json!({
206 "request_id": "t",
207 "event": {
208 "kind": "table",
209 "headers": ["id", "size"],
210 "rows": [["tiny", "75 MB"], ["base", "142 MB"]]
211 }
212 });
213 let frame = parse_command_output(&v).unwrap();
214 match frame.event {
215 CommandOutputEvent::Table { headers, rows } => {
216 assert_eq!(headers, vec!["id".to_string(), "size".to_string()]);
217 assert_eq!(rows.len(), 2);
218 assert_eq!(rows[0], vec!["tiny".to_string(), "75 MB".to_string()]);
219 }
220 other => panic!("expected Table, got {other:?}"),
221 }
222 }
223
224 #[test]
225 fn rejects_missing_request_id() {
226 let v = json!({"event": {"kind": "done"}});
227 assert!(parse_command_output(&v).is_err());
228 }
229
230 #[test]
231 fn rejects_empty_request_id() {
232 let v = json!({"request_id": "", "event": {"kind": "done"}});
233 assert!(parse_command_output(&v).is_err());
234 }
235
236 #[test]
237 fn rejects_unknown_kind() {
238 let v = json!({"request_id": "x", "event": {"kind": "weird"}});
239 let err = parse_command_output(&v).unwrap_err();
240 assert!(err.contains("unknown"));
241 }
242
243 #[test]
244 fn rejects_text_without_content() {
245 let v = json!({"request_id":"x","event":{"kind":"text"}});
246 assert!(parse_command_output(&v).is_err());
247 }
248
249 #[test]
250 fn rejects_error_with_empty_content() {
251 let v = json!({"request_id":"x","event":{"kind":"error","content":""}});
252 assert!(parse_command_output(&v).is_err());
253 }
254
255 #[test]
256 fn rejects_table_with_non_string_cell() {
257 let v = json!({
258 "request_id":"x",
259 "event":{"kind":"table","headers":["a"],"rows":[[1]]}
260 });
261 assert!(parse_command_output(&v).is_err());
262 }
263}