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: today
170/// only its **effective model**. Carried by
171/// [`crate::event::AgentEvent::Sessions`], so a client can label each member by
172/// its own model instead of substituting the root's.
173#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
174pub struct SessionInfo {
175 pub id: SessionId,
176 /// The session's effective model id, when the server knows one (a worker
177 /// registered with its model); `None` for a peer whose model is unknown.
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub model: Option<String>,
180 /// Server-side liveness (see [`MemberState`] above). `#[serde(default)]`
181 /// keeps an older peer's `SessionInfo` (no `state` key) round-tripping as
182 /// `Idle`.
183 #[serde(default)]
184 pub state: MemberState,
185}
186
187/// Inbound intent: the requests a session's mailbox accepts.
188///
189/// Every variant maps one-to-one onto an existing [`crate::agent::Agent`]
190/// method, so the current session behavior is expressible without the actor
191/// inventing anything new:
192///
193/// | `Request` | today |
194/// |-----------|-------|
195/// | `Submit` | `Agent::run` |
196/// | `Notify` | `Agent::notify` (append, no turn) |
197/// | `Interrupt` | `Agent::steer` |
198/// | `Wake` | run — a turn even when idle |
199/// | `Cancel` | `Agent::cancel` |
200/// | `SetModel` | `Agent::set_model` |
201/// | `SetEffort` | `Agent::set_effort` |
202/// | `Compact` | `Agent::compact` |
203///
204/// The one exception is [`Request::ListSessions`]: a transport-level probe the
205/// server answers, not an `Agent` method.
206///
207/// Inbound payloads carry user **content**, not an `AgentMessage`: a remote peer
208/// must not be able to inject an `Assistant`/`ToolResult` message and impersonate
209/// the model. The session wraps content as a user message, exactly as
210/// `Agent::run` does. `GetHistory` is a read: it carries no payload and its
211/// answer comes back as [`AgentEvent::History`]
212/// (with the other replies — [`AgentEvent::Ack`],
213/// and [`AgentEvent::Error`] — alongside it).
214/// These reply variants are never *streamed*: they answer a specific request,
215/// which the transport correlates (an in-process oneshot today, the envelope's
216/// `reply_to` on the wire).
217#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
218#[serde(tag = "type", rename_all = "snake_case")]
219pub enum Request {
220 /// Start a turn: persist the text as a user message and run the loop until
221 /// it stops. The run's progress and result stream back as [`AgentEvent`]s.
222 Submit { text: String },
223
224 /// Append `content` to the conversation *without* starting a turn (recorded
225 /// now if idle, or at the next turn boundary if a run is in flight). The
226 /// "tell, don't ask" delivery mode — `Agent::notify`.
227 Notify { content: String },
228
229 /// Inject `content` at the next turn boundary of the in-flight run — a soft
230 /// interrupt that does not cancel the run. Formerly `Steer`; `"steer"`
231 /// still deserializes here.
232 #[serde(alias = "steer")]
233 Interrupt { content: String },
234
235 /// Wake the session: run `content` as a turn **even if it is idle** (mid-run
236 /// it rides the follow-up channel instead). Formerly `FollowUp`;
237 /// `"follow_up"` still deserializes here.
238 /// `"follow_up"` still deserializes here.
239 #[serde(alias = "follow_up")]
240 Wake { content: String },
241
242 /// Cancel the in-flight run (and any retry/backoff wait). Idempotent.
243 Cancel,
244
245 /// Swap the model for subsequent turns.
246 SetModel { model: String },
247
248 /// Set the reasoning effort, or clear it (`None` = send nothing).
249 SetEffort { effort: Option<String> },
250
251 /// Summarize the older prefix of the conversation now, optionally focused
252 /// by `instructions`.
253 Compact { instructions: Option<String> },
254
255 /// Toggle plan mode. The actor flips the agent's shared `PlanModeHandle`
256 /// (removing mutating tools + mutating `bash`) and recomposes the system
257 /// prompt. Infallible — replies [`AgentEvent::Ack`].
258 SetPlanMode { on: bool },
259
260 /// Ask a side question (`/btw`): answer `text` tool-free, from the current
261 /// context, WITHOUT recording it. A read — carries user content, names no
262 /// `AgentMessage` (see the enum doc above). Reply:
263 /// [`AgentEvent::SideAnswer`]. A `SideAsk` arriving mid-run is deferred to
264 /// the next turn boundary (like any other inbox request), not refused.
265 SideAsk { text: String },
266 /// Read back the conversation so far. Reply:
267 /// [`AgentEvent::History`].
268 GetHistory,
269
270 /// Ask the **transport** for the sessions a server serves — serve order,
271 /// root first. The one request that names no `Agent` method: an in-process
272 /// session has no roster, so the actor answers it with [`AgentEvent::Ack`]
273 /// (like [`Request::Unknown`]); a socket server intercepts it and replies
274 /// [`AgentEvent::Sessions`].
275 ListSessions,
276
277 /// Define a worker **on a served peer** (§8, S4). Like [`Request::ListSessions`]
278 /// it names no `Agent` method: a socket server intercepts it and asks an
279 /// injected handler to build a session, replying [`AgentEvent::Spawned`] with
280 /// its address (or a correlated [`AgentEvent::Error`]). Mirrors the CLI's
281 /// worker spec, so a peer can customize the worker's model, role, tool set,
282 /// `read_only`, `effort`, and provider. An in-process session has no
283 /// factory, so the actor answers
284 /// it with [`AgentEvent::Error`].
285 Define {
286 /// The worker's address; auto-assigned (`w1`, `w2`, …) when absent.
287 name: Option<String>,
288 /// Model id override; `None` inherits the server's model.
289 model: Option<String>,
290 /// Role text appended to the worker's system prompt.
291 role: Option<String>,
292 /// Tool allow-list; `None` = the full default set.
293 tools: Option<Vec<String>>,
294 /// Provider base URL override; `None` inherits the server's.
295 base_url: Option<String>,
296 /// Provider API key override; `None` inherits the server's.
297 api_key: Option<String>,
298 /// Enforce read-only for the defined worker (D1/D2); default false.
299 #[serde(default)]
300 read_only: bool,
301 /// Reasoning-effort override (D3); `None` inherits, synonyms clear.
302 #[serde(default)]
303 effort: Option<String>,
304 },
305
306 /// Forward-compatibility catch-all: an older peer must skip a request it
307 /// does not understand rather than fail the connection.
308 #[serde(other)]
309 Unknown,
310}
311
312/// Versioned, addressed envelope around a [`Request`] or an [`AgentEvent`].
313///
314/// Generic over the body so the same frame carries either direction: the
315/// request-to-a-session frame and the event-from-a-session frame have identical
316/// headers. The body is `#[serde(flatten)]`ed, so on the wire a frame is one
317/// flat JSON object — `{"v":1,"id":7,"session":"s1","type":"cancel"}`.
318#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
319pub struct Frame<P> {
320 /// Protocol major version ([`PROTOCOL_VERSION`]).
321 pub v: u32,
322 /// Sender-assigned id, monotonic per connection; answers echo it in
323 /// `reply_to`.
324 pub id: u64,
325 /// The request this frame answers, if any. Streaming events that are not
326 /// direct replies leave it absent.
327 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub reply_to: Option<u64>,
329 /// The session this frame concerns — the target of a request, the origin of
330 /// an event. The whole addressing scheme.
331 pub session: SessionId,
332 /// The sender's address, when it is not the transport's own client (an
333 /// in-process handle leaves it unset; an A2A peer sets its `"agent:<id>"`).
334 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub sender: Option<SessionId>,
336 #[serde(flatten)]
337 pub body: P,
338}
339
340impl<P> Frame<P> {
341 /// A fresh top-level frame at the current version, not a reply.
342 pub fn new(id: u64, session: SessionId, body: P) -> Self {
343 Self {
344 v: PROTOCOL_VERSION,
345 id,
346 reply_to: None,
347 session,
348 sender: None,
349 body,
350 }
351 }
352}
353
354/// A client-to-session frame.
355pub type RequestFrame = Frame<Request>;
356/// A session-to-client frame.
357pub type EventFrame = Frame<AgentEvent>;
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use crate::event::{TodoItem, TodoStatus};
363 use serde::de::DeserializeOwned;
364 use serde_json::json;
365
366 fn roundtrip<P>(p: P, expected_tag: &str)
367 where
368 P: Serialize + DeserializeOwned + PartialEq + std::fmt::Debug,
369 {
370 let v = serde_json::to_value(&p).unwrap();
371 assert_eq!(v["type"], expected_tag);
372 let back: P = serde_json::from_value(v).unwrap();
373 assert_eq!(back, p);
374 }
375
376 #[test]
377 fn request_roundtrip_all_variants() {
378 roundtrip(Request::Submit { text: "hi".into() }, "submit");
379 roundtrip(Request::Cancel, "cancel");
380 roundtrip(Request::SetModel { model: "m1".into() }, "set_model");
381 roundtrip(
382 Request::SetEffort {
383 effort: Some("high".into()),
384 },
385 "set_effort",
386 );
387 roundtrip(Request::SetEffort { effort: None }, "set_effort");
388 roundtrip(
389 Request::Compact {
390 instructions: Some("focus on the parser".into()),
391 },
392 "compact",
393 );
394 roundtrip(Request::Compact { instructions: None }, "compact");
395 roundtrip(Request::SetPlanMode { on: true }, "set_plan_mode");
396 roundtrip(Request::Notify { content: "n".into() }, "notify");
397 roundtrip(Request::Interrupt { content: "i".into() }, "interrupt");
398 roundtrip(Request::Wake { content: "w".into() }, "wake");
399 roundtrip(Request::SideAsk { text: "why?".into() }, "side_ask");
400 roundtrip(Request::ListSessions, "list_sessions");
401 roundtrip(
402 Request::Define {
403 name: Some("w1".into()),
404 model: Some("m".into()),
405 role: Some("reviewer".into()),
406 tools: Some(vec!["read".into()]),
407 base_url: Some("http://w/v1".into()),
408 api_key: Some("k".into()),
409 read_only: true,
410 effort: Some("high".into()),
411 },
412 "define",
413 );
414 roundtrip(
415 Request::Define {
416 name: None,
417 model: None,
418 role: None,
419 tools: None,
420 base_url: None,
421 api_key: None,
422 read_only: false,
423 effort: None,
424 },
425 "define",
426 );
427 }
428
429 #[test]
430 fn legacy_verb_tags_deserialize_to_canonical_variants() {
431 let steer: Request = serde_json::from_str(r#"{"type":"steer","content":"s"}"#).unwrap();
432 assert_eq!(steer, Request::Interrupt { content: "s".into() });
433 let follow: Request = serde_json::from_str(r#"{"type":"follow_up","content":"f"}"#).unwrap();
434 assert_eq!(follow, Request::Wake { content: "f".into() });
435 }
436
437 #[test]
438 fn request_unknown_tag_is_caught_not_fatal() {
439 let back: Request = serde_json::from_str(r#"{"type":"from_the_future"}"#).unwrap();
440 assert_eq!(back, Request::Unknown);
441 }
442
443 #[test]
444 fn is_inbound_marks_only_the_delivery_verbs() {
445 assert!(is_inbound(&Request::Notify { content: "x".into() }));
446 assert!(is_inbound(&Request::Interrupt { content: "x".into() }));
447 assert!(is_inbound(&Request::Wake { content: "x".into() }));
448 assert!(!is_inbound(&Request::Submit { text: "x".into() }));
449 assert!(!is_inbound(&Request::GetHistory));
450 assert!(!is_inbound(&Request::SideAsk { text: "x".into() }));
451 assert!(!is_inbound(&Request::SetPlanMode { on: true }));
452 assert!(!is_inbound(&Request::Cancel));
453 assert!(!is_inbound(&Request::ListSessions));
454 assert!(!is_inbound(&Request::Define {
455 name: None,
456 model: None,
457 role: None,
458 tools: None,
459 base_url: None,
460 api_key: None,
461 read_only: false,
462 effort: None,
463 }));
464 }
465
466 #[test]
467 fn frame_flattens_body_and_omits_absent_reply_to() {
468 let f = Frame::new(7, SessionId::new("s1"), Request::Cancel);
469 let v = serde_json::to_value(&f).unwrap();
470 assert_eq!(
471 v,
472 json!({"v": PROTOCOL_VERSION, "id": 7, "session": "s1", "type": "cancel"})
473 );
474 let back: Frame<Request> = serde_json::from_value(v).unwrap();
475 assert_eq!(back, f);
476 }
477
478 #[test]
479 fn frame_carries_reply_to_and_an_event_body() {
480 let f = EventFrame {
481 v: PROTOCOL_VERSION,
482 id: 8,
483 reply_to: Some(7),
484 session: SessionId::from("s1"),
485 sender: None,
486 body: AgentEvent::AgentEnd,
487 };
488 let v = serde_json::to_value(&f).unwrap();
489 assert_eq!(
490 v,
491 json!({"v": PROTOCOL_VERSION, "id": 8, "reply_to": 7, "session": "s1", "type": "agent_end"})
492 );
493 // `AgentEvent` is not `PartialEq` (see event.rs); compare re-serialized
494 // JSON, matching that module's round-trip convention.
495 let back: EventFrame = serde_json::from_value(v.clone()).unwrap();
496 assert_eq!(serde_json::to_value(&back).unwrap(), v);
497 }
498
499 #[test]
500 fn frame_carries_a_todo_event_body() {
501 let f = EventFrame {
502 v: PROTOCOL_VERSION,
503 id: 9,
504 reply_to: None,
505 session: SessionId::from("s1"),
506 sender: None,
507 body: AgentEvent::Todo {
508 todos: vec![TodoItem {
509 content: "write tests".into(),
510 status: TodoStatus::Pending,
511 }],
512 },
513 };
514 let v = serde_json::to_value(&f).unwrap();
515 assert_eq!(
516 v,
517 json!({
518 "v": PROTOCOL_VERSION, "id": 9, "session": "s1", "type": "todo",
519 "todos": [{"content": "write tests", "status": "pending"}],
520 })
521 );
522 let back: EventFrame = serde_json::from_value(v.clone()).unwrap();
523 assert_eq!(serde_json::to_value(&back).unwrap(), v);
524 }
525
526 #[test]
527 fn session_id_serializes_transparently() {
528 assert_eq!(
529 serde_json::to_value(SessionId::new("abc")).unwrap(),
530 json!("abc")
531 );
532 assert_eq!(SessionId::new("abc").as_str(), "abc");
533 assert_eq!(SessionId::new("abc").to_string(), "abc");
534 assert_eq!(SessionId::from("x"), SessionId(String::from("x")));
535 assert_eq!(SessionId::user().as_str(), USER);
536 assert_eq!(SessionId::agent("abc").as_str(), "agent:abc");
537 }
538}