Skip to main content

agent_engine/extensions/
commands.rs

1//! Plugin interactive command (`/command`) output event types and parser.
2//!
3//! Phase B Phase 2 contract — see
4//! `docs/plans/2026-05-03-extension-contracts-for-rich-plugins.md`.
5//!
6//! Wire shape (`command.output` JSON-RPC notification params):
7//!
8//! ```jsonc
9//! {
10//!   "request_id": "abc-123",
11//!   "event": { "kind": "text"|"system"|"error"|"table"|"done", ... }
12//! }
13//! ```
14//!
15//! Synaps subscribes to `command.output` notifications matching the
16//! caller-issued `request_id` after invoking `command.invoke`.
17
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21/// A single structured event emitted by an interactive plugin command.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23#[serde(tag = "kind", rename_all = "snake_case")]
24pub enum CommandOutputEvent {
25    /// Markdown-rendered chat text.
26    Text { content: String },
27    /// System-style chat message (wrapped/dimmed in the UI).
28    System { content: String },
29    /// Error chat message.
30    Error { content: String },
31    /// A tabular result.
32    Table {
33        headers: Vec<String>,
34        rows: Vec<Vec<String>>,
35    },
36    /// End-of-stream marker.
37    Done,
38}
39
40/// Parsed `command.output` notification frame.
41#[derive(Debug, Clone, PartialEq)]
42pub struct CommandOutputFrame {
43    pub request_id: String,
44    pub event: CommandOutputEvent,
45}
46
47/// Parse a `command.output` JSON-RPC notification's `params`.
48///
49/// Accepts both `{"request_id": "...", "event": {"kind": "..."}}` and
50/// the flat shape `{"request_id": "...", "kind": "...", ...}` for tolerance.
51pub 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            // Flat shape: rebuild a nested object containing all non-request_id keys.
69            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
79/// Parse just the `event` payload (`{"kind": "...", ...}`).
80pub 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}