Skip to main content

agent_engine/sidecar/
protocol.rs

1//! Sidecar protocol types — line-JSON over stdio.
2//!
3//! The host treats sidecars as lego-block processes: it starts them, sends
4//! generic trigger frames, and consumes generic status/text frames. Plugin
5//! semantics live entirely outside core.
6//!
7//! Wire format: one JSON object per line on the sidecar's stdin/stdout.
8
9use serde::{Deserialize, Serialize};
10
11/// Protocol version understood by this build.
12pub const SIDECAR_PROTOCOL_VERSION: u16 = 2;
13
14/// Commands sent from Synaps CLI to the sidecar (one per line, JSON).
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(tag = "type", rename_all = "snake_case")]
17pub enum SidecarCommand {
18    /// Plugin-defined initialization payload. Core forwards the value
19    /// verbatim and does not interpret its schema.
20    Init {
21        config: serde_json::Value,
22    },
23    /// Generic activation trigger. `name` and `payload` are plugin-defined.
24    Trigger {
25        name: String,
26        #[serde(default, skip_serializing_if = "Option::is_none")]
27        payload: Option<serde_json::Value>,
28    },
29    Shutdown,
30}
31
32/// How text emitted by a sidecar should be applied to the input buffer.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum InsertTextMode {
36    Append,
37    Final,
38    Replace,
39}
40
41/// Frames emitted by the sidecar (one per line, JSON).
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(tag = "type", rename_all = "snake_case")]
44pub enum SidecarFrame {
45    Hello {
46        protocol_version: u16,
47        extension: String,
48        #[serde(default)]
49        capabilities: Vec<String>,
50    },
51    Status {
52        state: String,
53        #[serde(default, skip_serializing_if = "Option::is_none")]
54        label: Option<String>,
55        #[serde(default)]
56        capabilities: Vec<String>,
57    },
58    InsertText {
59        text: String,
60        mode: InsertTextMode,
61    },
62    Error {
63        message: String,
64    },
65    #[serde(other)]
66    Custom,
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn command_round_trip_init() {
75        let cmd = SidecarCommand::Init {
76            config: serde_json::json!({
77                "protocol_version": SIDECAR_PROTOCOL_VERSION,
78                "plugin_config": { "mode": "whatever-the-plugin-wants" }
79            }),
80        };
81        let json = serde_json::to_string(&cmd).unwrap();
82        assert!(json.contains("\"type\":\"init\""));
83        assert!(json.contains("whatever-the-plugin-wants"));
84        let parsed: SidecarCommand = serde_json::from_str(&json).unwrap();
85        assert_eq!(parsed, cmd);
86    }
87
88    #[test]
89    fn command_round_trip_trigger_shutdown() {
90        let commands = vec![
91            SidecarCommand::Trigger {
92                name: "press".into(),
93                payload: None,
94            },
95            SidecarCommand::Trigger {
96                name: "release".into(),
97                payload: Some(serde_json::json!({"source":"keybind"})),
98            },
99            SidecarCommand::Shutdown,
100        ];
101        for cmd in commands {
102            let json = serde_json::to_string(&cmd).unwrap();
103            let parsed: SidecarCommand = serde_json::from_str(&json).unwrap();
104            assert_eq!(parsed, cmd);
105        }
106    }
107
108    #[test]
109    fn trigger_payload_is_optional() {
110        let without_payload = serde_json::to_string(&SidecarCommand::Trigger {
111            name: "tap".into(),
112            payload: None,
113        })
114        .unwrap();
115        assert!(without_payload.contains("\"type\":\"trigger\""));
116        assert!(!without_payload.contains("payload"));
117
118        let with_payload = serde_json::to_string(&SidecarCommand::Trigger {
119            name: "tap".into(),
120            payload: Some(serde_json::json!({"count":2})),
121        })
122        .unwrap();
123        assert!(with_payload.contains("payload"));
124    }
125
126    #[test]
127    fn frame_round_trip_hello_status_insert_error() {
128        let frames = vec![
129            SidecarFrame::Hello {
130                protocol_version: SIDECAR_PROTOCOL_VERSION,
131                extension: "example-plugin".to_string(),
132                capabilities: vec!["anything".into(), "input.text".into()],
133            },
134            SidecarFrame::Status {
135                state: "busy".into(),
136                label: Some("Working".into()),
137                capabilities: vec![],
138            },
139            SidecarFrame::InsertText {
140                text: "partial".into(),
141                mode: InsertTextMode::Append,
142            },
143            SidecarFrame::InsertText {
144                text: "done".into(),
145                mode: InsertTextMode::Final,
146            },
147            SidecarFrame::Error {
148                message: "missing model".into(),
149            },
150        ];
151        for frame in frames {
152            let json = serde_json::to_string(&frame).unwrap();
153            let parsed: SidecarFrame = serde_json::from_str(&json).unwrap();
154            assert_eq!(parsed, frame);
155        }
156    }
157
158    #[test]
159    fn parses_generic_hello_line() {
160        let line = r#"{"type":"hello","protocol_version":2,"extension":"example-plugin","capabilities":["text.insert"]}"#;
161        let parsed: SidecarFrame = serde_json::from_str(line).unwrap();
162        match parsed {
163            SidecarFrame::Hello {
164                protocol_version,
165                extension,
166                capabilities,
167            } => {
168                assert_eq!(protocol_version, SIDECAR_PROTOCOL_VERSION);
169                assert_eq!(extension, "example-plugin");
170                assert_eq!(capabilities, vec!["text.insert".to_string()]);
171            }
172            other => panic!("expected Hello, got {:?}", other),
173        }
174    }
175
176    #[test]
177    fn parses_insert_text_final_line() {
178        let line = r#"{"type":"insert_text","text":"hello from a plugin","mode":"final"}"#;
179        let parsed: SidecarFrame = serde_json::from_str(line).unwrap();
180        assert_eq!(
181            parsed,
182            SidecarFrame::InsertText {
183                text: "hello from a plugin".into(),
184                mode: InsertTextMode::Final,
185            }
186        );
187    }
188}