Skip to main content

pixelactions_core/
protocol.rs

1//! The line protocol: one JSON object per line, in and out.
2//!
3//! This is what makes a bot in *any* language possible without this
4//! crate knowing anything about that language. Every language can spawn
5//! a process and write lines to a pipe; none of them need FFI, native
6//! modules, or a package we have to release in lockstep.
7//!
8//! The framing is the one LSP, esbuild, and MCP all converged on:
9//!
10//! - one JSON object per line, no embedded newlines
11//! - **stdout carries only protocol messages; stderr is for logs** — a
12//!   client must never read stderr output as failure
13//! - closing stdin is the graceful shutdown; there is no daemon, no PID
14//!   file, and no lifetime for anyone to manage
15//! - a version handshake on the first exchange, so changing the protocol
16//!   later does not break every existing bot (ripgrep's `--json` shipped
17//!   without one, and that is the cautionary tale)
18//!
19//! Requests carry an `id` that responses echo. Only one request is in
20//! flight at a time today — that keeps the implementation free of an
21//! async runtime — but the `id` means concurrency can arrive later
22//! without a breaking change.
23
24use serde::{Deserialize, Serialize};
25
26use crate::convert::ResolvedPoint;
27use crate::flow::{Settings, Step};
28
29/// The protocol version this build speaks. Bumped only for changes a
30/// client could notice.
31pub const PROTOCOL_VERSION: u32 = 1;
32
33/// A request from the client.
34#[derive(Debug, Clone, PartialEq)]
35pub struct Request {
36    /// Echoed on the response. Any value the client finds useful.
37    pub id: Option<u64>,
38    pub body: RequestBody,
39}
40
41/// What the client wants done.
42///
43/// `do` names either a control message or a step, using the same
44/// vocabulary as flow files and chained argv — so `{"do":"click"}` is
45/// the wire form of `action = "click"`. Because one key names both
46/// kinds, the two are separated by hand in [`parse_request`] rather
47/// than by a derive; the payoff is that a client writes the verb it
48/// already knows instead of nesting it under a wrapper.
49#[derive(Debug, Clone, PartialEq)]
50pub enum RequestBody {
51    /// First message: agree on a version before anything else happens,
52    /// and optionally set the run settings for the whole session — the
53    /// same fields a flow file's `[settings]` table carries, so there is
54    /// nothing new to learn and no CLI flag to keep in sync.
55    Hello {
56        version: u32,
57        settings: Option<Settings>,
58    },
59    /// Perform one step.
60    Step { step: Step },
61    /// Re-locate every region and report where they are, without acting.
62    Relocate,
63    /// End the session. Closing stdin does the same thing.
64    Bye,
65}
66
67/// A response to the client. Always exactly one per request.
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69pub struct Response {
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub id: Option<u64>,
72    #[serde(flatten)]
73    pub body: ResponseBody,
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77#[serde(tag = "result", rename_all = "snake_case")]
78pub enum ResponseBody {
79    /// Answer to `hello`: what this build speaks and what it can do.
80    Welcome {
81        version: u32,
82        /// Step names this build understands, so a client can degrade
83        /// gracefully instead of guessing.
84        verbs: Vec<String>,
85        session: String,
86    },
87    /// A step ran. `outcome` is the same vocabulary a run report uses:
88    /// `verified` · `executed` · `failed`.
89    ///
90    /// A step that ran and failed honestly is a `done` with
91    /// `outcome: "failed"` and a `detail`; a request that could not be
92    /// understood or resolved is an `error`. That is the same line the
93    /// exit codes draw between 1 and 2.
94    Done {
95        outcome: String,
96        #[serde(skip_serializing_if = "Vec::is_empty", default)]
97        points: Vec<ResolvedPoint>,
98        /// Why it failed, in words — including the evidence a timeout
99        /// gathered. Absent when nothing went wrong.
100        #[serde(skip_serializing_if = "Option::is_none", default)]
101        detail: Option<String>,
102        elapsed_ms: u64,
103    },
104    /// Regions were re-located.
105    Located {
106        /// Labels whose current position differs from the session.
107        moved: Vec<String>,
108        /// Labels that could not be found unambiguously — acting on
109        /// these would be acting blind.
110        missing: Vec<String>,
111    },
112    /// Acknowledgment of `bye`. The last line before stdout closes.
113    Closed,
114    /// The request could not be honored. `detail` is written for a human
115    /// to read and act on, not for a stack trace.
116    Error { detail: String },
117}
118
119impl Response {
120    pub fn error(id: Option<u64>, detail: impl Into<String>) -> Self {
121        Self {
122            id,
123            body: ResponseBody::Error {
124                detail: detail.into(),
125            },
126        }
127    }
128
129    /// Serialize to a single line, newline included.
130    ///
131    /// Serialization cannot fail for these types, but a client would be
132    /// left hanging if it ever did — so a failure becomes a protocol
133    /// error rather than a panic or a silent drop.
134    pub fn to_line(&self) -> String {
135        match serde_json::to_string(self) {
136            Ok(json) => format!("{json}\n"),
137            Err(error) => format!(
138                "{{\"result\":\"error\",\"detail\":\"could not serialize response: {error}\"}}\n"
139            ),
140        }
141    }
142}
143
144/// Best-effort read of a line's `id`, for correlating an error with the
145/// request that caused it.
146///
147/// [`parse_request`] fails before it can build a `Request`, which would
148/// otherwise leave a client with several requests outstanding unable to
149/// tell which one was rejected. A line too broken to yield an `id` gets
150/// `None` — the one case where a client has to fall back to order.
151pub fn id_in(line: &str) -> Option<u64> {
152    serde_json::from_str::<serde_json::Value>(line)
153        .ok()?
154        .get("id")?
155        .as_u64()
156}
157
158/// Parse one line into a request.
159///
160/// Control verbs are recognized first; anything else is handed to the
161/// step deserializer under its own tag, so step shapes stay defined in
162/// exactly one place (`flow::Step`) and can never drift from the flow
163/// file format.
164pub fn parse_request(line: &str) -> Result<Request, String> {
165    let mut value: serde_json::Value = serde_json::from_str(line).map_err(|error| {
166        format!(
167            "not a valid request: {error}. \
168             Expected one JSON object per line, e.g. \
169             {{\"id\":1,\"do\":\"click\",\"target\":\"submit\"}}"
170        )
171    })?;
172    let object = value
173        .as_object_mut()
174        .ok_or_else(|| "a request must be a JSON object".to_string())?;
175
176    let id = object.get("id").and_then(serde_json::Value::as_u64);
177    let verb = object
178        .remove("do")
179        .ok_or_else(|| "a request needs a \"do\" naming the action, e.g. \"click\"".to_string())?;
180    let verb = verb
181        .as_str()
182        .ok_or_else(|| "\"do\" must be a string".to_string())?
183        .to_string();
184
185    let body = match verb.as_str() {
186        "hello" => {
187            let version = object
188                .get("version")
189                .and_then(serde_json::Value::as_u64)
190                .and_then(|version| u32::try_from(version).ok())
191                .ok_or_else(|| {
192                    format!(
193                        "hello needs a \"version\" number; \
194                         this build speaks version {PROTOCOL_VERSION}"
195                    )
196                })?;
197            let settings = match object.remove("settings") {
198                None => None,
199                Some(value) => {
200                    Some(serde_json::from_value::<Settings>(value).map_err(|error| {
201                        format!("hello carried settings this build cannot read: {error}")
202                    })?)
203                }
204            };
205            RequestBody::Hello { version, settings }
206        }
207        "relocate" => RequestBody::Relocate,
208        "bye" => RequestBody::Bye,
209        step_name => {
210            object.remove("id");
211            object.insert(
212                "action".to_string(),
213                serde_json::Value::String(step_name.to_string()),
214            );
215            // serde already names every known action on an unknown
216            // variant, and names the missing key on a malformed one —
217            // appending our own list would only say it twice.
218            let step: Step = serde_json::from_value(value)
219                .map_err(|error| format!("cannot read {step_name:?} as an action: {error}"))?;
220            RequestBody::Step { step }
221        }
222    };
223    Ok(Request { id, body })
224}
225
226/// Every step name this build understands — the handshake's answer to
227/// "what can you do".
228pub fn supported_verbs() -> Vec<String> {
229    [
230        "click",
231        "double_click",
232        "drag",
233        "type",
234        "key",
235        "verify",
236        "wait_for",
237        "wait_gone",
238        "pause",
239    ]
240    .iter()
241    .map(|s| (*s).to_string())
242    .collect()
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn a_step_request_reads_the_same_vocabulary_as_flows() {
251        let request = parse_request(r#"{"id":7,"do":"click","target":"submit"}"#).expect("valid");
252        assert_eq!(request.id, Some(7));
253        assert_eq!(
254            request.body,
255            RequestBody::Step {
256                step: Step::Click {
257                    target: "submit".into()
258                }
259            }
260        );
261    }
262
263    #[test]
264    fn every_step_kind_crosses_the_wire() {
265        for (line, expected) in [
266            (
267                r#"{"do":"type","text":"hello"}"#,
268                Step::Type {
269                    text: "hello".into(),
270                },
271            ),
272            (
273                r#"{"do":"key","chord":"cmd+s"}"#,
274                Step::Key {
275                    chord: "cmd+s".into(),
276                },
277            ),
278            (r#"{"do":"pause","ms":250}"#, Step::Pause { ms: 250 }),
279            (
280                r#"{"do":"drag","from":"a","to":"b"}"#,
281                Step::Drag {
282                    from: "a".into(),
283                    to: "b".into(),
284                },
285            ),
286            (
287                r#"{"do":"wait_for","target":"dialog"}"#,
288                Step::WaitFor {
289                    target: "dialog".into(),
290                },
291            ),
292        ] {
293            let request = parse_request(line).expect("valid");
294            assert_eq!(
295                request.body,
296                RequestBody::Step { step: expected },
297                "line: {line}"
298            );
299        }
300    }
301
302    #[test]
303    fn an_id_is_optional_and_echoed_when_present() {
304        assert_eq!(
305            parse_request(r#"{"do":"relocate"}"#).expect("valid").id,
306            None
307        );
308        let response = Response::error(Some(3), "nope");
309        assert!(response.to_line().contains("\"id\":3"));
310    }
311
312    #[test]
313    fn a_response_is_exactly_one_line() {
314        let response = Response {
315            id: Some(1),
316            body: ResponseBody::Done {
317                outcome: "verified".into(),
318                points: Vec::new(),
319                detail: None,
320                elapsed_ms: 12,
321            },
322        };
323        let line = response.to_line();
324        assert_eq!(line.matches('\n').count(), 1, "exactly one newline");
325        assert!(line.ends_with('\n'));
326        assert!(!line.trim_end().contains('\n'), "no embedded newlines");
327    }
328
329    #[test]
330    fn garbage_becomes_a_readable_error_not_a_crash() {
331        let error = parse_request("not json at all").expect_err("should fail");
332        assert!(
333            error.contains("one JSON object per line"),
334            "teaches the shape: {error}"
335        );
336    }
337
338    #[test]
339    fn an_unknown_verb_is_refused_rather_than_guessed() {
340        assert!(parse_request(r#"{"do":"teleport","target":"home"}"#).is_err());
341    }
342
343    #[test]
344    fn a_request_without_a_verb_says_so() {
345        let error = parse_request(r#"{"id":1}"#).expect_err("no verb");
346        assert!(error.contains("\"do\""), "names the missing key: {error}");
347    }
348
349    #[test]
350    fn a_malformed_step_names_the_field_it_wanted() {
351        // Right verb, wrong shape: click needs a target.
352        let error = parse_request(r#"{"do":"click"}"#).expect_err("missing target");
353        assert!(error.contains("target"), "names the missing field: {error}");
354    }
355
356    #[test]
357    fn an_unknown_verb_lists_the_ones_that_exist() {
358        let error = parse_request(r#"{"do":"teleport"}"#).expect_err("unknown");
359        assert!(error.contains("wait_for"), "lists what exists: {error}");
360    }
361
362    #[test]
363    fn the_handshake_advertises_what_this_build_can_do() {
364        let request = parse_request(r#"{"id":0,"do":"hello","version":1}"#).expect("valid");
365        assert_eq!(
366            request.body,
367            RequestBody::Hello {
368                version: PROTOCOL_VERSION,
369                settings: None
370            }
371        );
372        let verbs = supported_verbs();
373        assert!(verbs.contains(&"click".to_string()));
374        assert!(verbs.contains(&"wait_for".to_string()));
375    }
376
377    #[test]
378    fn a_handshake_can_carry_run_settings() {
379        let request =
380            parse_request(r#"{"do":"hello","version":1,"settings":{"timeout_ms":30000}}"#)
381                .expect("valid");
382        let RequestBody::Hello { settings, .. } = request.body else {
383            panic!("expected a hello");
384        };
385        let settings = settings.expect("settings were sent");
386        assert_eq!(settings.timeout_ms, 30_000);
387        // Unnamed fields keep their defaults rather than becoming zero.
388        assert!(settings.relocate);
389    }
390
391    #[test]
392    fn a_handshake_without_a_version_says_which_one_we_speak() {
393        let error = parse_request(r#"{"do":"hello"}"#).expect_err("no version");
394        assert!(
395            error.contains(&PROTOCOL_VERSION.to_string()),
396            "names the version to send: {error}"
397        );
398    }
399
400    #[test]
401    fn a_typo_in_settings_is_an_error_not_a_silent_default() {
402        // Our own config is parsed strictly — see AGENTS.md.
403        let error = parse_request(r#"{"do":"hello","version":1,"settings":{"timeout":1}}"#)
404            .expect_err("unknown field");
405        assert!(error.contains("settings"), "says where: {error}");
406    }
407
408    #[test]
409    fn a_failed_step_carries_its_reason() {
410        let response = Response {
411            id: None,
412            body: ResponseBody::Done {
413                outcome: "failed".into(),
414                points: Vec::new(),
415                detail: Some("timed out after 10000ms".into()),
416                elapsed_ms: 10_004,
417            },
418        };
419        assert!(response.to_line().contains("timed out"));
420    }
421
422    #[test]
423    fn a_rejected_request_can_still_be_matched_to_its_id() {
424        // The step is malformed, so parsing fails — but a client with
425        // several requests in flight still needs to know which one.
426        assert_eq!(id_in(r#"{"id":42,"do":"click"}"#), Some(42));
427        assert_eq!(id_in(r#"{"do":"click"}"#), None);
428        assert_eq!(id_in("not json"), None);
429    }
430
431    #[test]
432    fn responses_round_trip() {
433        let response = Response {
434            id: Some(9),
435            body: ResponseBody::Located {
436                moved: vec!["submit".into()],
437                missing: Vec::new(),
438            },
439        };
440        let line = response.to_line();
441        let back: Response = serde_json::from_str(line.trim_end()).expect("round trip");
442        assert_eq!(back, response);
443    }
444}