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    /// Every JSON example from §4 of the web-ui-plan must parse and
325    /// re-serialize to the same value.
326    #[test]
327    fn wire_examples_round_trip() {
328        let examples: Vec<(&str, &str)> = vec![
329            // Snapshot (abridged).
330            (
331                "snapshot",
332                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}"#,
333            ),
334            // Entries delta.
335            (
336                "entries",
337                r#"{"seq":42,"type":"entries","pane":"main","from":3,"entries":[{"entry":{"kind":"assistant","data":"Done — it was an off-by-one.","time":1753500100}}]}"#,
338            ),
339            // Tool entry with display model.
340            (
341                "tool_entry",
342                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"}}}"#,
343            ),
344            // Sub-agent pane id.
345            (
346                "sub_pane",
347                r#"{"type":"submit","pane":{"sub":7},"text":"fix the bug"}"#,
348            ),
349            // Command.
350            (
351                "command",
352                r#"{"type":"command","pane":{"sub":7},"line":"/status"}"#,
353            ),
354            // Resume.
355            ("resume", r#"{"type":"resume","seq":41}"#),
356            // Escalation approval, both directions.
357            (
358                "approval_requested",
359                r#"{"seq":77,"type":"approval_requested","id":"esc-1","command":"git push origin main","reason":"runs outside the OS sandbox","rules":["git push"]}"#,
360            ),
361            (
362                "approval_closed",
363                r#"{"seq":78,"type":"approval_closed","id":"esc-1"}"#,
364            ),
365            (
366                "answer_approval",
367                r#"{"type":"answer_approval","id":"esc-1","decision":"session"}"#,
368            ),
369        ];
370
371        for (name, json) in examples {
372            // Parse as generic Value, then re-serialize, then compare.
373            let v: serde_json::Value =
374                serde_json::from_str(json).unwrap_or_else(|_| panic!("{name}: parse failed"));
375            let round = serde_json::to_string(&v).unwrap();
376            let v2: serde_json::Value = serde_json::from_str(&round).unwrap();
377            assert_eq!(v, v2, "{name}: round-trip mismatch");
378        }
379    }
380
381    /// `PaneId` wire shape: `Main` ⇄ `"main"`, `Sub(7)` ⇄ `{"sub":7}`.
382    #[test]
383    fn pane_id_wire_shape() {
384        // Main
385        let main: WirePaneId = serde_json::from_str(r#""main""#).unwrap();
386        assert_eq!(main, WirePaneId::Main);
387        let back = serde_json::to_string(&WirePaneId::Main).unwrap();
388        assert_eq!(back, r#""main""#);
389
390        // Sub(7)
391        let sub7: WirePaneId = serde_json::from_str(r#"{"sub":7}"#).unwrap();
392        assert_eq!(sub7, WirePaneId::Sub(7));
393        let back = serde_json::to_string(&WirePaneId::Sub(7)).unwrap();
394        assert_eq!(back, r#"{"sub":7}"#);
395    }
396
397    /// A `ServerFrame` with `seq` and a flattened `ServerMsg` serializes
398    /// with `seq` at the top level next to `type`.
399    #[test]
400    fn server_frame_flattens_seq() {
401        let frame = ServerFrame {
402            seq: 1,
403            msg: ServerMsg::Notice {
404                text: "hello".into(),
405            },
406        };
407        let json = serde_json::to_string(&frame).unwrap();
408        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
409        assert_eq!(v["seq"], serde_json::json!(1));
410        assert_eq!(v["type"], serde_json::json!("notice"));
411        assert_eq!(v["text"], serde_json::json!("hello"));
412
413        // Round-trip through ServerFrame.
414        let parsed: ServerFrame = serde_json::from_str(&json).unwrap();
415        assert_eq!(parsed, frame);
416    }
417
418    /// `ClientMsg` round-trips for every variant.
419    #[test]
420    fn client_msg_round_trip() {
421        let cases = vec![
422            ClientMsg::Submit {
423                pane: WirePaneId::Main,
424                text: "hi".into(),
425            },
426            ClientMsg::Command {
427                pane: WirePaneId::Sub(9),
428                line: "/status".into(),
429            },
430            ClientMsg::Cancel {
431                pane: WirePaneId::Main,
432            },
433            ClientMsg::SwitchPane {
434                pane: WirePaneId::Sub(3),
435            },
436            ClientMsg::Resume { seq: 42 },
437        ];
438        for msg in cases {
439            let json = serde_json::to_string(&msg).unwrap();
440            let back: ClientMsg = serde_json::from_str(&json).unwrap();
441            assert_eq!(msg, back, "round-trip: {json}");
442        }
443    }
444}