Skip to main content

wcode_harness/
protocol.rs

1//! The kernel's request/event interface — one protocol for every peer.
2//!
3//! The kernel already exposes one half of a protocol: [`AgentEvent`] is a
4//! typed, serializable stream of everything a session does (text deltas, tool
5//! lifecycle, turn boundaries). This module adds the inbound half, [`Request`],
6//! and a versioned, addressed envelope, [`Frame`], so that a client and a peer
7//! become the same kind of thing:
8//!
9//! - a **request** goes into a session's mailbox,
10//! - an **event** comes out of its stream.
11//!
12//! A TUI client, a script, and another session differ only in which address
13//! ([`Frame::session`]) they hold and how they render the stream — not in the
14//! protocol they speak. That is what lets the client seam and the future
15//! agent-to-agent seam share one implementation.
16//!
17//! Transport is deliberately absent: today the seam is an in-process
18//! `tokio::mpsc`; framing and sockets live above the kernel (a future
19//! `wcode-protocol` crate). This module is serde types only, so it adds no
20//! behavior — it is the shape the actor (S1) and the socket (S2) will speak.
21//!
22//! See `docs/interface-protocol-brainstorm.md` for the design and the staged
23//! path that lands these types.
24
25use serde::{Deserialize, Serialize};
26
27use crate::event::AgentEvent;
28
29/// Wire-protocol major version. Bumped only by a breaking change; additive
30/// changes leave it alone. [`Request`] already carries a [`Request::Unknown`]
31/// fallback so an older peer survives a newer request; the matching catch-all
32/// on the event side lands with the actor (S1), where an unknown event can be
33/// handled rather than silently dropped.
34pub const PROTOCOL_VERSION: u32 = 1;
35
36/// The well-known address of the human peer (brainstorm §5.2): the sender an
37/// in-process actor attributes an inbound message to until addressing lands
38/// (S4-2).
39pub const USER: &str = "user";
40
41/// Whether `request` is an A2A inbound message — the delivery verbs a peer sends
42/// (`Notify`/`Interrupt`/`Wake`), as opposed to a local command. The actor runs
43/// its `before_inbound` policy on exactly these.
44pub fn is_inbound(request: &Request) -> bool {
45    matches!(
46        request,
47        Request::Notify { .. } | Request::Interrupt { .. } | Request::Wake { .. }
48    )
49}
50
51/// A session's stable address.
52///
53/// Reuses the id already written in `SessionEntry::Header`, so a session is
54/// reachable by the same name it persists under. Transitively serialized as a
55/// bare string. Newtypes a plain `String` only to keep addressing legible at
56/// call sites — the conventions (the human is `"user"`, a peer is
57/// `"agent:<id>"`) are not encoded in the type yet.
58#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
59#[serde(transparent)]
60pub struct SessionId(pub String);
61
62impl SessionId {
63    pub fn new(id: impl Into<String>) -> Self {
64        Self(id.into())
65    }
66
67    /// The well-known address of the human peer (see [`USER`]).
68    pub fn user() -> Self {
69        Self(USER.to_string())
70    }
71
72    /// A peer session's address: `"agent:<id>"` (§5.2).
73    pub fn agent(id: impl AsRef<str>) -> Self {
74        Self(format!("agent:{}", id.as_ref()))
75    }
76
77    pub fn as_str(&self) -> &str {
78        &self.0
79    }
80}
81
82impl From<String> for SessionId {
83    fn from(id: String) -> Self {
84        Self(id)
85    }
86}
87
88impl From<&str> for SessionId {
89    fn from(id: &str) -> Self {
90        Self(id.to_string())
91    }
92}
93
94impl std::fmt::Display for SessionId {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        f.write_str(&self.0)
97    }
98}
99
100/// A session's run-state — the server-side liveness fact (bug 2).
101///
102/// The wire twin of the TUI's client-local `TeamState`: born at the actor's
103/// `run()` boundary (actor.rs), mirrored into the registry roster, and carried
104/// on [`SessionInfo`] so it RIDES the existing
105/// [`crate::event::AgentEvent::Sessions`] push — no new `Request`/`Reply`
106/// variant is needed for the query (`Request::ListSessions` already returns
107/// `Sessions`; making it `state`-aware is the whole poll path).
108///
109/// serde shape: `snake_case` — `"idle" | "running" | "done" | "failed"`.
110/// `Default = Idle`, so an older server's [`SessionInfo`] (no `state` key)
111/// still deserializes.
112#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum MemberState {
115    /// Not running and never has — a fresh worker before its first turn.
116    #[default]
117    Idle,
118    /// A run (a `Submit`/`Wake` turn) is in flight right now.
119    Running,
120    /// The last run finished normally; idle but not virgin.
121    Done,
122    /// The last run ended on a run-failure (`StopReason::Error`); sticky
123    /// across the run boundary, cleared by the next start.
124    Failed,
125}
126
127impl MemberState {
128    /// Lowercase label the roster push / `peers` listing render.
129    pub fn label(self) -> &'static str {
130        match self {
131            MemberState::Idle => "idle",
132            MemberState::Running => "running",
133            MemberState::Done => "done",
134            MemberState::Failed => "failed",
135        }
136    }
137}
138
139// Follow-on (not this change): the map from `MemberState` to the TUI's
140// `TeamState` does NOT live here — the harness must not depend on `wcode-tui`
141// (layering). That map, and the glyph styling it drives, is a separate TUI
142// change outside the roster work.
143
144#[cfg(test)]
145mod member_state_tests {
146    use super::*;
147    use serde_json::json;
148
149    #[test]
150    fn serde_is_snake_case() {
151        assert_eq!(
152            serde_json::to_value(MemberState::Running).unwrap(),
153            json!("running")
154        );
155        assert_eq!(
156            serde_json::from_str::<MemberState>("\"failed\"").unwrap(),
157            MemberState::Failed
158        );
159        assert_eq!(MemberState::Done.label(), "done");
160    }
161
162    #[test]
163    fn absent_state_field_defaults_to_idle() {
164        let info: SessionInfo = serde_json::from_value(json!({"id": "agent:w1"})).unwrap();
165        assert_eq!(info.id, SessionId::agent("w1"));
166        assert_eq!(info.state, MemberState::Idle);
167    }
168}
169/// A served session's address plus the little metadata a roster shows: its
170/// **effective model**, **reasoning effort**, and **context window** (each
171/// `None` when the server does not know it). Carried by
172/// [`crate::event::AgentEvent::Sessions`], so a client can label each member by
173/// its own model instead of substituting the root's.
174#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
175pub struct SessionInfo {
176    pub id: SessionId,
177    /// The session's effective model id, when the server knows one (a worker
178    /// registered with its model); `None` for a peer whose model is unknown.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub model: Option<String>,
181    /// The session's effective reasoning effort, when the server knows one (a
182    /// worker set to `high`, a root launched `--effort xhigh`); `None` when
183    /// unset/inherited. Absent on the wire for an older peer.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub effort: Option<String>,
186    /// The session's context window in tokens, when known (the root's
187    /// `limits::model_limit(base_url, model).context`, else
188    /// `limits::DEFAULT_CONTEXT_WINDOW`). The GAUGE's denominator.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub context_window: Option<u64>,
191    /// Server-side liveness (see [`MemberState`] above). `#[serde(default)]`
192    /// keeps an older peer's `SessionInfo` (no `state` key) round-tripping as
193    /// `Idle`.
194    #[serde(default)]
195    pub state: MemberState,
196}
197
198/// Inbound intent: the requests a session's mailbox accepts.
199///
200/// Every variant maps one-to-one onto an existing [`crate::agent::Agent`]
201/// method, so the current session behavior is expressible without the actor
202/// inventing anything new:
203///
204/// | `Request` | today |
205/// |-----------|-------|
206/// | `Submit` | `Agent::run` |
207/// | `Notify` | `Agent::notify` (append, no turn) |
208/// | `Interrupt` | `Agent::steer` |
209/// | `Wake` | run — a turn even when idle |
210/// | `Cancel` | `Agent::cancel` |
211/// | `SetModel` | `Agent::set_model` |
212/// | `SetEffort` | `Agent::set_effort` |
213/// | `SetTitle` | `Agent::set_title` |
214/// | `Compact` | `Agent::compact` |
215/// | `Usage` | `crate::stats::session_stats` + `pricing::cost_of` |
216///
217/// The one exception is [`Request::ListSessions`]: a transport-level probe the
218/// server answers, not an `Agent` method.
219///
220/// Inbound payloads carry user **content**, not an `AgentMessage`: a remote peer
221/// must not be able to inject an `Assistant`/`ToolResult` message and impersonate
222/// the model. The session wraps content as a user message, exactly as
223/// `Agent::run` does. `GetHistory` is a read: it carries no payload and its
224/// answer comes back as [`AgentEvent::History`]
225/// (with the other replies — [`AgentEvent::Ack`],
226/// and [`AgentEvent::Error`] — alongside it).
227/// These reply variants are never *streamed*: they answer a specific request,
228/// which the transport correlates (an in-process oneshot today, the envelope's
229/// `reply_to` on the wire).
230#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
231#[serde(tag = "type", rename_all = "snake_case")]
232pub enum Request {
233    /// Start a turn: persist the text as a user message and run the loop until
234    /// it stops. The run's progress and result stream back as [`AgentEvent`]s.
235    Submit { text: String },
236
237    /// Append `content` to the conversation *without* starting a turn (recorded
238    /// now if idle, or at the next turn boundary if a run is in flight). The
239    /// "tell, don't ask" delivery mode — `Agent::notify`.
240    Notify { content: String },
241
242    /// Inject `content` at the next turn boundary of the in-flight run — a soft
243    /// interrupt that does not cancel the run. Formerly `Steer`; `"steer"`
244    /// still deserializes here.
245    #[serde(alias = "steer")]
246    Interrupt { content: String },
247
248    /// Wake the session: run `content` as a turn **even if it is idle** (mid-run
249    /// it rides the follow-up channel instead). Formerly `FollowUp`;
250    /// `"follow_up"` still deserializes here.
251    /// `"follow_up"` still deserializes here.
252    #[serde(alias = "follow_up")]
253    Wake { content: String },
254
255    /// Cancel the in-flight run (and any retry/backoff wait). Idempotent.
256    Cancel,
257
258    /// Swap the model for subsequent turns.
259    SetModel { model: String },
260
261    /// Set the reasoning effort, or clear it (`None` = send nothing).
262    SetEffort { effort: Option<String> },
263
264    /// Summarize the older prefix of the conversation now, optionally focused
265    /// by `instructions`.
266    Compact { instructions: Option<String> },
267
268    /// Toggle plan mode. The actor flips the agent's shared `PlanModeHandle`
269    /// (removing mutating tools + mutating `bash`) and recomposes the system
270    /// prompt. Infallible — replies [`AgentEvent::Ack`].
271    SetPlanMode { on: bool },
272
273    /// Rename this session (W031 §5.6): append a [`crate::session::SessionEntry::Title`]
274    /// and stream [`crate::event::AgentEvent::SessionTitle`]. The verb behind
275    /// `/rename <text>` on both human surfaces and behind `--title`. Infallible
276    /// in intent — an append failure answers [`crate::event::AgentEvent::Error`].
277    ///
278    /// The title is NOT run through `crate::title::sanitize`: an explicit rename
279    /// is the human's own words. An empty string stores an empty title (clearing
280    /// is not in W031's scope).
281    ///
282    /// Additive on the wire (`{"type":"set_title","title":"…"}`), so
283    /// [`PROTOCOL_VERSION`] stays 1: an OLD peer catches it as [`Request::Unknown`]
284    /// and answers `Ack`, which reads as "unavailable" — the [`Request::Usage`]
285    /// precedent.
286    SetTitle { title: String },
287
288    /// Ask a side question (`/btw`): answer `text` tool-free, from the current
289    /// context, WITHOUT recording it. A read — carries user content, names no
290    /// `AgentMessage` (see the enum doc above). Reply:
291    /// [`AgentEvent::SideAnswer`]. A `SideAsk` arriving mid-run is answered from
292    /// the latest turn-boundary snapshot (like `GetHistory`), not refused.
293    SideAsk { text: String },
294    /// Read back the conversation so far. Reply:
295    /// [`AgentEvent::History`].
296    GetHistory,
297
298    /// A compact read of the session's latest activity — the text of its most
299    /// recent assistant message — WITHOUT shipping the transcript (unlike
300    /// [`Request::GetHistory`]). A `member`-style peek: the reply is one small
301    /// string, not the whole history. Reply: [`AgentEvent::Status`].
302    Status,
303    /// Read the agent's OWN settings — model, reasoning effort, plan mode and
304    /// the model's context window — WITHOUT shipping the transcript. The
305    /// [`Request::Status`] rationale applies (a folded summary, one small
306    /// payload, no baseline), and the [`Request::Usage`] answer rule does too:
307    /// a `Settings` arriving mid-run is answered from the turn-boundary
308    /// snapshot, never deferred, because the TUI repaints from it.
309    ///
310    /// Why it exists at all: two client-side `Status` fields are written once,
311    /// at launch (`main.rs:1099` / `:2013`), and never again — `effort` (which
312    /// `ui.rs:838` prints as `model · effort`) and `context_limit` (the gauge's
313    /// denominator) — so a client that cannot READ them renders a stale number
314    /// wherever it shows one. A settings panel would only make that lie more
315    /// prominent.
316    ///
317    /// Reply: [`crate::event::AgentEvent::Settings`]. Additive on the wire (a
318    /// peer that predates it catches `#[serde(other)] Unknown` at `:366` and
319    /// answers `Ack`, read as "unavailable" — never as a value), so
320    /// [`PROTOCOL_VERSION`] stays 1, exactly as `Usage` argues at `:316`-`:319`.
321    Settings,
322
323    /// Read this session's usage report: turns, summed tokens, the cache hit
324    /// rate, per-tool `calls`/`errors`/`result_bytes`, and a `$` cost when the
325    /// model is priced — WITHOUT shipping the transcript (unlike
326    /// [`Request::GetHistory`]). The [`Request::Status`] rationale applies: a
327    /// folded summary, one small payload, no baseline needed on the client.
328    ///
329    /// Answered from the actor's own `Agent`/`SideHandle`, which is where the
330    /// provider lives (`agent.rs:410` `llm: LlmOpts,`), so the actor — not the
331    /// front-end — prices the session. A `Usage` arriving mid-run is answered
332    /// from the turn-boundary snapshot (like `GetHistory`), never deferred.
333    /// Reply: [`AgentEvent::Usage`].
334    ///
335    /// On a remote peer that predates the variant, `#[serde(other)] Unknown`
336    /// catches it and the actor answers `Ack` — read as "unavailable", NOT as
337    /// zero usage (WS-D/WS-E decide the rendering).
338    Usage,
339
340    /// Ask the **transport** for the sessions a server serves — serve order,
341    /// root first. The one request that names no `Agent` method: an in-process
342    /// session has no roster, so the actor answers it with [`AgentEvent::Ack`]
343    /// (like [`Request::Unknown`]); a socket server intercepts it and replies
344    /// [`AgentEvent::Sessions`].
345    ListSessions,
346
347    /// Ask the **transport** for the model catalog the provider advertises
348    /// (`streamfn::list_models`). Mirrors [`Request::ListSessions`]: it names
349    /// no `Agent` method, so an in-process session answers it with
350    /// [`crate::event::AgentEvent::Ack`] (like [`Request::Unknown`]); a socket
351    /// server intercepts it and replies [`crate::event::AgentEvent::Models`].
352    ListModels,
353
354    /// Define a worker **on a served peer** (§8, S4). Like [`Request::ListSessions`]
355    /// it names no `Agent` method: a socket server intercepts it and asks an
356    /// injected handler to build a session, replying [`AgentEvent::Spawned`] with
357    /// its address (or a correlated [`AgentEvent::Error`]). Mirrors the CLI's
358    /// worker spec, so a peer can customize the worker's model, role, tool set,
359    /// `read_only`, `effort`, and provider. An in-process session has no
360    /// factory, so the actor answers
361    /// it with [`AgentEvent::Error`].
362    Define {
363        /// The worker's address; auto-assigned (`w1`, `w2`, …) when absent.
364        name: Option<String>,
365        /// Model id override; `None` inherits the server's model.
366        model: Option<String>,
367        /// Role text appended to the worker's system prompt.
368        role: Option<String>,
369        /// Tool allow-list; `None` = the full default set.
370        tools: Option<Vec<String>>,
371        /// Provider base URL override; `None` inherits the server's.
372        base_url: Option<String>,
373        /// Provider API key override; `None` inherits the server's.
374        api_key: Option<String>,
375        /// Enforce read-only for the defined worker (D1/D2); default false.
376        #[serde(default)]
377        read_only: bool,
378        /// Reasoning-effort override (D3); `None` inherits, synonyms clear.
379        #[serde(default)]
380        effort: Option<String>,
381    },
382
383    /// Forward-compatibility catch-all: an older peer must skip a request it
384    /// does not understand rather than fail the connection.
385    #[serde(other)]
386    Unknown,
387}
388
389/// Versioned, addressed envelope around a [`Request`] or an [`AgentEvent`].
390///
391/// Generic over the body so the same frame carries either direction: the
392/// request-to-a-session frame and the event-from-a-session frame have identical
393/// headers. The body is `#[serde(flatten)]`ed, so on the wire a frame is one
394/// flat JSON object — `{"v":1,"id":7,"session":"s1","type":"cancel"}`.
395#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
396pub struct Frame<P> {
397    /// Protocol major version ([`PROTOCOL_VERSION`]).
398    pub v: u32,
399    /// Sender-assigned id, monotonic per connection; answers echo it in
400    /// `reply_to`.
401    pub id: u64,
402    /// The request this frame answers, if any. Streaming events that are not
403    /// direct replies leave it absent.
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub reply_to: Option<u64>,
406    /// The session this frame concerns — the target of a request, the origin of
407    /// an event. The whole addressing scheme.
408    pub session: SessionId,
409    /// The sender's address, when it is not the transport's own client (an
410    /// in-process handle leaves it unset; an A2A peer sets its `"agent:<id>"`).
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub sender: Option<SessionId>,
413    #[serde(flatten)]
414    pub body: P,
415}
416
417impl<P> Frame<P> {
418    /// A fresh top-level frame at the current version, not a reply.
419    pub fn new(id: u64, session: SessionId, body: P) -> Self {
420        Self {
421            v: PROTOCOL_VERSION,
422            id,
423            reply_to: None,
424            session,
425            sender: None,
426            body,
427        }
428    }
429}
430
431/// A client-to-session frame.
432pub type RequestFrame = Frame<Request>;
433/// A session-to-client frame.
434pub type EventFrame = Frame<AgentEvent>;
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use crate::event::{TodoItem, TodoStatus};
440    use serde::de::DeserializeOwned;
441    use serde_json::json;
442
443    fn roundtrip<P>(p: P, expected_tag: &str)
444    where
445        P: Serialize + DeserializeOwned + PartialEq + std::fmt::Debug,
446    {
447        let v = serde_json::to_value(&p).unwrap();
448        assert_eq!(v["type"], expected_tag);
449        let back: P = serde_json::from_value(v).unwrap();
450        assert_eq!(back, p);
451    }
452
453    #[test]
454    fn request_roundtrip_all_variants() {
455        roundtrip(Request::Submit { text: "hi".into() }, "submit");
456        roundtrip(Request::Cancel, "cancel");
457        roundtrip(Request::SetModel { model: "m1".into() }, "set_model");
458        roundtrip(
459            Request::SetEffort {
460                effort: Some("high".into()),
461            },
462            "set_effort",
463        );
464        roundtrip(Request::SetEffort { effort: None }, "set_effort");
465        roundtrip(Request::Settings, "settings");
466        roundtrip(
467            Request::Compact {
468                instructions: Some("focus on the parser".into()),
469            },
470            "compact",
471        );
472        roundtrip(Request::Compact { instructions: None }, "compact");
473        roundtrip(Request::SetPlanMode { on: true }, "set_plan_mode");
474roundtrip(Request::SetTitle { title: "w031 titles".into() }, "set_title");
475        roundtrip(Request::Notify { content: "n".into() }, "notify");
476        roundtrip(Request::Interrupt { content: "i".into() }, "interrupt");
477        roundtrip(Request::Wake { content: "w".into() }, "wake");
478        roundtrip(Request::SideAsk { text: "why?".into() }, "side_ask");
479        roundtrip(Request::Status, "status");
480        roundtrip(Request::Usage, "usage");
481        roundtrip(Request::ListSessions, "list_sessions");
482        roundtrip(Request::ListModels, "list_models");
483        roundtrip(
484            Request::Define {
485                name: Some("w1".into()),
486                model: Some("m".into()),
487                role: Some("reviewer".into()),
488                tools: Some(vec!["read".into()]),
489                base_url: Some("http://w/v1".into()),
490                api_key: Some("k".into()),
491                read_only: true,
492                effort: Some("high".into()),
493            },
494            "define",
495        );
496        roundtrip(
497            Request::Define {
498                name: None,
499                model: None,
500                role: None,
501                tools: None,
502                base_url: None,
503                api_key: None,
504                read_only: false,
505                effort: None,
506            },
507            "define",
508        );
509    }
510
511    #[test]
512    fn usage_request_roundtrips_with_costs() {
513        roundtrip(Request::Usage, "usage");
514        roundtrip(
515            AgentEvent::Usage {
516                stats: crate::stats::SessionStats {
517                    turns: 1,
518                    input_tokens: 1_000,
519                    output_tokens: 500,
520                    cache_read_tokens: 400,
521                    cache_turns: 1,
522                    tool_errors: 1,
523                    cost: Some(crate::stats::Cost {
524                        micro_usd: 391,
525                        unpriced_turns: 0,
526                    }),
527                    ..Default::default()
528                },
529            },
530            "usage",
531        );
532        let frame = RequestFrame::new(7, SessionId::new("s1"), Request::Usage);
533        let back: RequestFrame =
534            serde_json::from_value(serde_json::to_value(&frame).unwrap()).unwrap();
535        assert_eq!(back, frame);
536    }
537
538    #[test]
539    fn list_models_roundtrips_and_session_info_carries_effort_and_window() {
540        roundtrip(Request::ListModels, "list_models");
541
542        let info = SessionInfo {
543            id: SessionId::new("root"),
544            model: Some("m1".into()),
545            effort: Some("high".into()),
546            context_window: Some(200_000),
547            state: MemberState::Idle,
548        };
549        let v = serde_json::to_value(&info).unwrap();
550        assert_eq!(v["effort"], "high");
551        assert_eq!(v["context_window"], 200_000);
552        assert_eq!(serde_json::from_value::<SessionInfo>(v).unwrap(), info);
553
554        // Absent effort/window round-trip to None (older peer / unset) ...
555        let bare: SessionInfo = serde_json::from_value(json!({"id": "w1"})).unwrap();
556        assert!(bare.effort.is_none() && bare.context_window.is_none());
557        // ... and `skip_serializing_if` keeps them OFF the wire when None.
558        let out = serde_json::to_value(SessionInfo {
559            id: SessionId::new("w1"),
560            model: None,
561            effort: None,
562            context_window: None,
563            state: MemberState::Idle,
564        })
565        .unwrap();
566        assert!(out.get("effort").is_none() && out.get("context_window").is_none());
567    }
568
569    #[test]
570    fn legacy_verb_tags_deserialize_to_canonical_variants() {
571        let steer: Request = serde_json::from_str(r#"{"type":"steer","content":"s"}"#).unwrap();
572        assert_eq!(steer, Request::Interrupt { content: "s".into() });
573        let follow: Request = serde_json::from_str(r#"{"type":"follow_up","content":"f"}"#).unwrap();
574        assert_eq!(follow, Request::Wake { content: "f".into() });
575    }
576
577    #[test]
578    fn request_unknown_tag_is_caught_not_fatal() {
579        let back: Request = serde_json::from_str(r#"{"type":"from_the_future"}"#).unwrap();
580        assert_eq!(back, Request::Unknown);
581    }
582
583    #[test]
584    fn is_inbound_marks_only_the_delivery_verbs() {
585        assert!(is_inbound(&Request::Notify { content: "x".into() }));
586        assert!(is_inbound(&Request::Interrupt { content: "x".into() }));
587        assert!(is_inbound(&Request::Wake { content: "x".into() }));
588        assert!(!is_inbound(&Request::Submit { text: "x".into() }));
589        assert!(!is_inbound(&Request::GetHistory));
590        assert!(!is_inbound(&Request::Status));
591        assert!(!is_inbound(&Request::Usage));
592        assert!(!is_inbound(&Request::Settings));
593        assert!(!is_inbound(&Request::SideAsk { text: "x".into() }));
594        assert!(!is_inbound(&Request::SetPlanMode { on: true }));
595assert!(!is_inbound(&Request::SetTitle { title: "x".into() }));
596        assert!(!is_inbound(&Request::Cancel));
597        assert!(!is_inbound(&Request::ListSessions));
598        assert!(!is_inbound(&Request::ListModels));
599        assert!(!is_inbound(&Request::Define {
600            name: None,
601            model: None,
602            role: None,
603            tools: None,
604            base_url: None,
605            api_key: None,
606            read_only: false,
607            effort: None,
608        }));
609    }
610
611    #[test]
612    fn frame_flattens_body_and_omits_absent_reply_to() {
613        let f = Frame::new(7, SessionId::new("s1"), Request::Cancel);
614        let v = serde_json::to_value(&f).unwrap();
615        assert_eq!(
616            v,
617            json!({"v": PROTOCOL_VERSION, "id": 7, "session": "s1", "type": "cancel"})
618        );
619        let back: Frame<Request> = serde_json::from_value(v).unwrap();
620        assert_eq!(back, f);
621    }
622
623    #[test]
624    fn frame_carries_reply_to_and_an_event_body() {
625        let f = EventFrame {
626            v: PROTOCOL_VERSION,
627            id: 8,
628            reply_to: Some(7),
629            session: SessionId::from("s1"),
630            sender: None,
631            body: AgentEvent::AgentEnd,
632        };
633        let v = serde_json::to_value(&f).unwrap();
634        assert_eq!(
635            v,
636            json!({"v": PROTOCOL_VERSION, "id": 8, "reply_to": 7, "session": "s1", "type": "agent_end"})
637        );
638        // `AgentEvent` is not `PartialEq` (see event.rs); compare re-serialized
639        // JSON, matching that module's round-trip convention.
640        let back: EventFrame = serde_json::from_value(v.clone()).unwrap();
641        assert_eq!(serde_json::to_value(&back).unwrap(), v);
642    }
643
644    #[test]
645    fn frame_carries_a_todo_event_body() {
646        let f = EventFrame {
647            v: PROTOCOL_VERSION,
648            id: 9,
649            reply_to: None,
650            session: SessionId::from("s1"),
651            sender: None,
652            body: AgentEvent::Todo {
653                todos: vec![TodoItem {
654                    content: "write tests".into(),
655                    status: TodoStatus::Pending,
656                }],
657            },
658        };
659        let v = serde_json::to_value(&f).unwrap();
660        assert_eq!(
661            v,
662            json!({
663                "v": PROTOCOL_VERSION, "id": 9, "session": "s1", "type": "todo",
664                "todos": [{"content": "write tests", "status": "pending"}],
665            })
666        );
667        let back: EventFrame = serde_json::from_value(v.clone()).unwrap();
668        assert_eq!(serde_json::to_value(&back).unwrap(), v);
669    }
670
671    #[test]
672    fn session_id_serializes_transparently() {
673        assert_eq!(
674            serde_json::to_value(SessionId::new("abc")).unwrap(),
675            json!("abc")
676        );
677        assert_eq!(SessionId::new("abc").as_str(), "abc");
678        assert_eq!(SessionId::new("abc").to_string(), "abc");
679        assert_eq!(SessionId::from("x"), SessionId(String::from("x")));
680        assert_eq!(SessionId::user().as_str(), USER);
681        assert_eq!(SessionId::agent("abc").as_str(), "agent:abc");
682    }
683}