Skip to main content

hrdr_protocol/
lib.rs

1//! Wire types for hrdr's web protocol. The server and every client (browser
2//! WASM, a native webview) share this crate so the WS frames and REST payloads
3//! are a single source of truth.
4//!
5//! This crate is kept `wasm32`-clean on purpose: it depends only on serde, so
6//! it compiles in the browser (where `hrdr-agent` with its tokio/reqwest/zstd
7//! deps cannot).
8
9#[cfg(test)]
10extern crate hrdr_test_support;
11
12use serde::{Deserialize, Serialize};
13
14// ── pane identity ──────────────────────────────────────────────────────────
15
16/// Which conversation a message concerns. Mirrors `hrdr_agent::PaneId`.
17/// External tagging on purpose: serializes as `"main"` or `{"sub": 7}`.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum WirePaneId {
21    Main,
22    Sub(u64),
23}
24
25// ── transcript entry (byte-for-byte mirror of hrdr_agent::Entry JSON) ──────
26
27/// Byte-for-byte the JSON of `hrdr_agent::Entry`: flat kind/data + unix time.
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct WireEntry {
30    #[serde(flatten)]
31    pub kind: WireEntryKind,
32    /// Unix seconds — `Entry` serializes `DateTime<Local>` this way.
33    pub time: i64,
34}
35
36/// Matches `hrdr_agent::EntryKind`'s serde shape exactly. This is the one
37/// exception to "struct variants only" — it must mirror the externally-shaped
38/// serde, newtype variants included, so the round-trip test passes.
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
41pub enum WireEntryKind {
42    Header,
43    User(String),
44    Assistant(String),
45    Reasoning {
46        text: String,
47        #[serde(default)]
48        took_ms: Option<u64>,
49    },
50    Tool {
51        id: String,
52        name: String,
53        args: String,
54        result: String,
55        ok: bool,
56        done: bool,
57    },
58    System(String),
59    Notice(String),
60    Stats(String),
61    Diff(String),
62}
63
64// ── entry view (server-computed display model) ─────────────────────────────
65
66/// Server-computed display model that rides beside an entry.
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub struct WireEntryView {
69    pub entry: WireEntry,
70    /// For Tool entries: `hrdr_agent::tool_display(name, args)`, converted.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub tool: Option<WireToolDisplay>,
73    /// For Diff entries AND Tool entries whose body is Diff: each line of the
74    /// diff text classified by `hrdr_app::classify_diff_line`.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub diff_lines: Option<Vec<WireDiffLine>>,
77}
78
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80pub struct WireToolDisplay {
81    pub headline: String,
82    pub body: WireToolBody,
83}
84
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86#[serde(tag = "type", rename_all = "snake_case")]
87pub enum WireToolBody {
88    Shell { command: String },
89    Code { lang: String, content: String },
90    Diff {},
91    Read {},
92    Details { rows: Vec<(String, String)> },
93    Text {},
94}
95
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97pub struct WireDiffLine {
98    pub kind: WireDiffLineKind,
99    pub text: String,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub enum WireDiffLineKind {
105    Hunk,
106    Add,
107    Remove,
108    Meta,
109}
110
111// ── pane chrome ────────────────────────────────────────────────────────────
112
113/// Snapshot of one pane's chrome (list row + status-bar inputs live here).
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
115pub struct WirePane {
116    pub id: WirePaneId,
117    pub title: String,
118    /// Mirrors `hrdr_agent::PaneStatus`.
119    pub status: WirePaneStatus,
120    pub model: String,
121    pub provider: String,
122    pub effort: Option<String>,
123    /// Queued-but-undelivered user messages.
124    pub pending: Vec<String>,
125    pub compacting: bool,
126    pub turn: WireTurn,
127    pub todos: Vec<WireTodo>,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "snake_case")]
132pub enum WirePaneStatus {
133    Running,
134    Idle,
135    Done,
136}
137
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139pub struct WireTodo {
140    pub content: String,
141    pub status: String,
142}
143
144/// A snapshot of one pane's turn clock. Not the agent's live `TurnStats`
145/// (which holds `Instant`/`SystemTime` — not serde, not WASM), but the derived
146/// numbers the frontend needs.
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148pub struct WireTurn {
149    pub running: bool,
150    pub inferring: bool,
151    /// `TurnStats::infer_elapsed().as_millis()`.
152    pub elapsed_ms: u64,
153    /// Time-to-first-token in seconds.
154    pub ttft_secs: Option<f64>,
155    /// Streamed tokens per second of model working time.
156    pub tok_per_sec: f64,
157    pub out_tokens: usize,
158    /// Unix seconds — from `TurnStats::started_at` when present.
159    pub started_unix: Option<i64>,
160}
161
162// ── status bar ─────────────────────────────────────────────────────────────
163
164/// Pre-built status bar (server ran `hrdr_app::status_sections`).
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166pub struct WireStatus {
167    pub left: Vec<WireStatusSeg>,
168    pub right: Vec<WireStatusSeg>,
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172pub struct WireStatusSeg {
173    pub priority: u8,
174    pub runs: Vec<WireStatusRun>,
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub gauge: Option<WireGauge>,
177}
178
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180pub struct WireStatusRun {
181    pub text: String,
182    pub role: WireStatusRole,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(tag = "role", rename_all = "snake_case")]
187pub enum WireStatusRole {
188    Dir {},
189    Branch {},
190    TokensIn {},
191    TokensOut {},
192    CtxFill { level: WireCtxLevel },
193    CtxRest {},
194    CtxPlain {},
195    Provider {},
196    Model {},
197    Effort {},
198    Ttft {},
199    Session {},
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "snake_case")]
204pub enum WireCtxLevel {
205    Ok,
206    Warn,
207    Critical,
208}
209
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211pub struct WireGauge {
212    pub frac: f64,
213    pub level: WireCtxLevel,
214    pub label: String,
215}
216
217// ── messages ───────────────────────────────────────────────────────────────
218
219/// Every server frame carries a global sequence number.
220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
221pub struct ServerFrame {
222    pub seq: u64,
223    #[serde(flatten)]
224    pub msg: ServerMsg,
225}
226
227#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
228#[serde(tag = "type", rename_all = "snake_case")]
229pub enum ServerMsg {
230    /// First frame on connect (and after a failed resume): complete state.
231    Snapshot {
232        session_id: Option<String>,
233        session_name: String,
234        cwd: String,
235        panes: Vec<WirePane>,
236        active: WirePaneId,
237        status: WireStatus,
238        transcripts: Vec<PaneTranscript>,
239        show_thinking: bool,
240    },
241    /// Replace pane's entries from index `from` to the end with `entries`.
242    /// `from == 0` with empty `entries` = the transcript was cleared (/new).
243    Entries {
244        pane: WirePaneId,
245        from: usize,
246        entries: Vec<WireEntryView>,
247    },
248    /// Pane list / chrome changed (panes added, released, status, turn, todos).
249    Panes {
250        panes: Vec<WirePane>,
251        active: WirePaneId,
252    },
253    Status {
254        status: WireStatus,
255    },
256    /// A system line produced outside the fold (async command output).
257    Notice {
258        text: String,
259    },
260    /// The server asks the client to replace/augment its input box
261    /// (`CommandHost::set_input` / `prepend_input` / `insert_input`).
262    SetInput {
263        mode: InputSetMode,
264        text: String,
265    },
266    /// Resume accepted: client state is current up to `seq`; deltas follow.
267    Resumed {},
268    /// Auth failed / connection refused; the socket closes after this.
269    Error {
270        message: String,
271    },
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(rename_all = "snake_case")]
276pub enum InputSetMode {
277    Replace,
278    Prepend,
279    InsertAtCursor,
280}
281
282#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
283pub struct PaneTranscript {
284    pub pane: WirePaneId,
285    pub entries: Vec<WireEntryView>,
286}
287
288// ── client messages ────────────────────────────────────────────────────────
289
290#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
291#[serde(tag = "type", rename_all = "snake_case")]
292pub enum ClientMsg {
293    /// User pressed send. Routed via `AgentRegistry::send_prompt` (steer if a
294    /// turn is in flight, new turn if idle).
295    Submit {
296        pane: WirePaneId,
297        text: String,
298    },
299    /// A slash command line (leading '/'). Runs through `hrdr_app` dispatch.
300    Command {
301        pane: WirePaneId,
302        line: String,
303    },
304    /// Cancel the active turn on `pane` (abort task + clear_pending).
305    Cancel {
306        pane: WirePaneId,
307    },
308    SwitchPane {
309        pane: WirePaneId,
310    },
311    /// Reconnect: client last saw `seq`. Server replays buffered frames after
312    /// `seq`, or sends a fresh `Snapshot` if the buffer no longer reaches back.
313    Resume {
314        seq: u64,
315    },
316}
317
318// ── tests ──────────────────────────────────────────────────────────────────
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    /// Which concrete wire type a fixture is supposed to be. The examples run
325    /// in both directions, so a fixture has to say which one it is rather than
326    /// leaving the test to guess from the `type` tag.
327    enum Wire {
328        /// Server → client: a `ServerFrame` (`seq` + flattened `ServerMsg`).
329        Frame,
330        /// Client → server: a `ClientMsg`.
331        Client,
332        /// Not a whole frame — one element of an `entries`/`transcripts` list.
333        EntryView,
334    }
335
336    /// Parse `json` into `T`, serialize it back, and require the two JSON
337    /// values to be equal.
338    ///
339    /// Both halves matter. Parsing into the concrete type is what proves the
340    /// protocol can actually speak the message — a variant that doesn't exist
341    /// fails here. Comparing the re-serialized value against the original is
342    /// what catches a field that serde silently drops: an unknown key parses
343    /// fine into a struct that ignores it, and then vanishes on the way back
344    /// out, so only the round-trip comparison notices it was ever there.
345    fn assert_round_trips<T>(name: &str, json: &str)
346    where
347        T: Serialize + serde::de::DeserializeOwned,
348    {
349        let before: serde_json::Value =
350            serde_json::from_str(json).unwrap_or_else(|e| panic!("{name}: not valid JSON: {e}"));
351        let typed: T = serde_json::from_str(json).unwrap_or_else(|e| {
352            panic!(
353                "{name}: does not parse as {}: {e}",
354                std::any::type_name::<T>()
355            )
356        });
357        let after = serde_json::to_value(&typed).unwrap();
358        assert_eq!(before, after, "{name}: round-trip through the wire type");
359    }
360
361    /// Every example below is a message hrdr's web protocol claims to speak.
362    /// Each one must parse into the concrete type it is tagged with and
363    /// re-serialize to exactly the same JSON — see `assert_round_trips` for
364    /// what each half of that catches.
365    #[test]
366    fn wire_examples_round_trip() {
367        let examples: Vec<(Wire, &str, &str)> = vec![
368            // Snapshot (abridged).
369            (
370                Wire::Frame,
371                "snapshot",
372                r#"{"seq":1,"type":"snapshot","session_id":"fix-parser","session_name":"fix parser","cwd":"/home/me/proj","panes":[{"id":"main","title":"main","status":"idle","model":"gpt-5.5","provider":"openai","effort":null,"pending":[],"compacting":false,"turn":{"running":false,"inferring":false,"elapsed_ms":0,"ttft_secs":null,"tok_per_sec":0.0,"out_tokens":0,"started_unix":null},"todos":[]}],"active":"main","status":{"left":[],"right":[]},"transcripts":[{"pane":"main","entries":[{"entry":{"kind":"user","data":"hi","time":1753500000}}]}],"show_thinking":true}"#,
373            ),
374            // Entries delta.
375            (
376                Wire::Frame,
377                "entries",
378                r#"{"seq":42,"type":"entries","pane":"main","from":3,"entries":[{"entry":{"kind":"assistant","data":"Done — it was an off-by-one.","time":1753500100}}]}"#,
379            ),
380            // Notice, and the input the server pushes back at the client.
381            (
382                Wire::Frame,
383                "notice",
384                r#"{"seq":43,"type":"notice","text":"background task finished"}"#,
385            ),
386            (
387                Wire::Frame,
388                "set_input",
389                r#"{"seq":44,"type":"set_input","mode":"insert_at_cursor","text":"@src/lib.rs"}"#,
390            ),
391            // The two frames that end a connection's handshake, happily or not.
392            (Wire::Frame, "resumed", r#"{"seq":45,"type":"resumed"}"#),
393            (
394                Wire::Frame,
395                "error",
396                r#"{"seq":0,"type":"error","message":"bad token"}"#,
397            ),
398            // Tool entry with display model.
399            (
400                Wire::EntryView,
401                "tool_entry",
402                r#"{"entry":{"kind":"tool","data":{"id":"c1","name":"shell","args":"{\"command\":\"ls\"}","result":"src\n","ok":true,"done":true},"time":1753500050},"tool":{"headline":"","body":{"type":"shell","command":"ls"}}}"#,
403            ),
404            // Sub-agent pane id.
405            (
406                Wire::Client,
407                "sub_pane",
408                r#"{"type":"submit","pane":{"sub":7},"text":"fix the bug"}"#,
409            ),
410            // Command.
411            (
412                Wire::Client,
413                "command",
414                r#"{"type":"command","pane":{"sub":7},"line":"/status"}"#,
415            ),
416            // Cancel and pane switch.
417            (
418                Wire::Client,
419                "cancel",
420                r#"{"type":"cancel","pane":{"sub":7}}"#,
421            ),
422            (
423                Wire::Client,
424                "switch_pane",
425                r#"{"type":"switch_pane","pane":"main"}"#,
426            ),
427            // Resume.
428            (Wire::Client, "resume", r#"{"type":"resume","seq":41}"#),
429        ];
430
431        for (wire, name, json) in examples {
432            match wire {
433                Wire::Frame => assert_round_trips::<ServerFrame>(name, json),
434                Wire::Client => assert_round_trips::<ClientMsg>(name, json),
435                Wire::EntryView => assert_round_trips::<WireEntryView>(name, json),
436            }
437        }
438    }
439
440    /// `PaneId` wire shape: `Main` ⇄ `"main"`, `Sub(7)` ⇄ `{"sub":7}`.
441    #[test]
442    fn pane_id_wire_shape() {
443        // Main
444        let main: WirePaneId = serde_json::from_str(r#""main""#).unwrap();
445        assert_eq!(main, WirePaneId::Main);
446        let back = serde_json::to_string(&WirePaneId::Main).unwrap();
447        assert_eq!(back, r#""main""#);
448
449        // Sub(7)
450        let sub7: WirePaneId = serde_json::from_str(r#"{"sub":7}"#).unwrap();
451        assert_eq!(sub7, WirePaneId::Sub(7));
452        let back = serde_json::to_string(&WirePaneId::Sub(7)).unwrap();
453        assert_eq!(back, r#"{"sub":7}"#);
454    }
455
456    /// A `ServerFrame` with `seq` and a flattened `ServerMsg` serializes
457    /// with `seq` at the top level next to `type`.
458    #[test]
459    fn server_frame_flattens_seq() {
460        let frame = ServerFrame {
461            seq: 1,
462            msg: ServerMsg::Notice {
463                text: "hello".into(),
464            },
465        };
466        let json = serde_json::to_string(&frame).unwrap();
467        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
468        assert_eq!(v["seq"], serde_json::json!(1));
469        assert_eq!(v["type"], serde_json::json!("notice"));
470        assert_eq!(v["text"], serde_json::json!("hello"));
471
472        // Round-trip through ServerFrame.
473        let parsed: ServerFrame = serde_json::from_str(&json).unwrap();
474        assert_eq!(parsed, frame);
475    }
476
477    /// `ClientMsg` round-trips for every variant.
478    #[test]
479    fn client_msg_round_trip() {
480        let cases = vec![
481            ClientMsg::Submit {
482                pane: WirePaneId::Main,
483                text: "hi".into(),
484            },
485            ClientMsg::Command {
486                pane: WirePaneId::Sub(9),
487                line: "/status".into(),
488            },
489            ClientMsg::Cancel {
490                pane: WirePaneId::Main,
491            },
492            ClientMsg::SwitchPane {
493                pane: WirePaneId::Sub(3),
494            },
495            ClientMsg::Resume { seq: 42 },
496        ];
497        for msg in cases {
498            let json = serde_json::to_string(&msg).unwrap();
499            let back: ClientMsg = serde_json::from_str(&json).unwrap();
500            assert_eq!(msg, back, "round-trip: {json}");
501        }
502    }
503}