Skip to main content

synaps_cli/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()
140                                .map(str::to_string)
141                                .ok_or_else(|| "command.output table cell must be string".to_string())
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 { content: "hello".into() }
166        );
167    }
168
169    #[test]
170    fn parses_text_event_flat() {
171        let v = json!({"request_id": "r2", "kind": "text", "content": "hi"});
172        let frame = parse_command_output(&v).unwrap();
173        assert_eq!(frame.request_id, "r2");
174        assert_eq!(
175            frame.event,
176            CommandOutputEvent::Text { content: "hi".into() }
177        );
178    }
179
180    #[test]
181    fn parses_system_error_done() {
182        let frame = parse_command_output(
183            &json!({"request_id":"x","event":{"kind":"system","content":"sys"}}),
184        )
185        .unwrap();
186        assert!(matches!(frame.event, CommandOutputEvent::System { .. }));
187
188        let frame = parse_command_output(
189            &json!({"request_id":"x","event":{"kind":"error","content":"oops"}}),
190        )
191        .unwrap();
192        assert!(matches!(frame.event, CommandOutputEvent::Error { .. }));
193
194        let frame = parse_command_output(&json!({"request_id":"x","event":{"kind":"done"}})).unwrap();
195        assert_eq!(frame.event, CommandOutputEvent::Done);
196    }
197
198    #[test]
199    fn parses_table() {
200        let v = json!({
201            "request_id": "t",
202            "event": {
203                "kind": "table",
204                "headers": ["id", "size"],
205                "rows": [["tiny", "75 MB"], ["base", "142 MB"]]
206            }
207        });
208        let frame = parse_command_output(&v).unwrap();
209        match frame.event {
210            CommandOutputEvent::Table { headers, rows } => {
211                assert_eq!(headers, vec!["id".to_string(), "size".to_string()]);
212                assert_eq!(rows.len(), 2);
213                assert_eq!(rows[0], vec!["tiny".to_string(), "75 MB".to_string()]);
214            }
215            other => panic!("expected Table, got {other:?}"),
216        }
217    }
218
219    #[test]
220    fn rejects_missing_request_id() {
221        let v = json!({"event": {"kind": "done"}});
222        assert!(parse_command_output(&v).is_err());
223    }
224
225    #[test]
226    fn rejects_empty_request_id() {
227        let v = json!({"request_id": "", "event": {"kind": "done"}});
228        assert!(parse_command_output(&v).is_err());
229    }
230
231    #[test]
232    fn rejects_unknown_kind() {
233        let v = json!({"request_id": "x", "event": {"kind": "weird"}});
234        let err = parse_command_output(&v).unwrap_err();
235        assert!(err.contains("unknown"));
236    }
237
238    #[test]
239    fn rejects_text_without_content() {
240        let v = json!({"request_id":"x","event":{"kind":"text"}});
241        assert!(parse_command_output(&v).is_err());
242    }
243
244    #[test]
245    fn rejects_error_with_empty_content() {
246        let v = json!({"request_id":"x","event":{"kind":"error","content":""}});
247        assert!(parse_command_output(&v).is_err());
248    }
249
250    #[test]
251    fn rejects_table_with_non_string_cell() {
252        let v = json!({
253            "request_id":"x",
254            "event":{"kind":"table","headers":["a"],"rows":[[1]]}
255        });
256        assert!(parse_command_output(&v).is_err());
257    }
258}