Skip to main content

shore_protocol/
server_msg.rs

1use serde::{Deserialize, Serialize};
2
3use crate::error::ErrorCode;
4use crate::types::{CharacterInfo, Message, StreamMetadata};
5
6/// Server hello — sent after client connects.
7#[derive(Serialize, Deserialize, Debug, Clone)]
8pub struct ServerHello {
9    pub v: u32,
10    pub server_name: String,
11    #[serde(default)]
12    pub characters: Vec<CharacterInfo>,
13}
14
15/// Full state snapshot.
16#[derive(Serialize, Deserialize, Debug, Clone)]
17pub struct History {
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub rid: Option<String>,
20    pub messages: Vec<Message>,
21    /// Index of the first message that is still in the active prompt context.
22    ///
23    /// Normal push/handshake snapshots contain only active context and leave
24    /// this at zero. Bounded log/history responses may include durable archive
25    /// scrollback before this index; those messages are useful for humans but
26    /// are no longer part of the model's active conversation context.
27    #[serde(default, skip_serializing_if = "is_zero")]
28    pub active_start: usize,
29    #[serde(default)]
30    pub config: serde_json::Value,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub selected_character: Option<String>,
33    #[serde(default)]
34    pub revision: u64,
35}
36
37#[expect(
38    clippy::trivially_copy_pass_by_ref,
39    reason = "serde skip_serializing_if requires a &T predicate signature"
40)]
41fn is_zero(value: &usize) -> bool {
42    *value == 0
43}
44
45/// Server shutting down.
46#[derive(Serialize, Deserialize, Debug, Clone)]
47pub struct Shutdown {}
48
49/// Keepalive.
50#[derive(Serialize, Deserialize, Debug, Clone)]
51pub struct Ping {}
52
53/// Command result.
54#[derive(Serialize, Deserialize, Debug, Clone)]
55pub struct CommandOutput {
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub rid: Option<String>,
58    pub name: String,
59    pub data: serde_json::Value,
60}
61
62/// Error response.
63#[derive(Serialize, Deserialize, Debug, Clone)]
64pub struct Error {
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub rid: Option<String>,
67    pub code: ErrorCode,
68    pub message: String,
69}
70
71/// Begin streaming.
72#[derive(Serialize, Deserialize, Debug, Clone)]
73pub struct StreamStart {
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub rid: Option<String>,
76    #[serde(default)]
77    pub regen: bool,
78    /// Set when this frame belongs to a sub-agent's nested tool loop (the
79    /// `[subagents.<name>]` name behind an `ask_<name>` call). Clients render
80    /// it as attributed/nested activity; `None` is the primary model.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub subagent: Option<String>,
83}
84
85/// Partial content chunk.
86#[derive(Serialize, Deserialize, Debug, Clone)]
87pub struct StreamChunk {
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub rid: Option<String>,
90    pub text: String,
91    #[serde(default = "default_content_type")]
92    pub content_type: String,
93    /// Sub-agent name when this chunk is from a nested `ask_<name>` loop; see
94    /// [`StreamStart::subagent`].
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub subagent: Option<String>,
97}
98
99fn default_content_type() -> String {
100    "text".to_owned()
101}
102
103/// Done streaming.
104///
105/// A single `send`/`regen` can emit multiple `StreamEnd` frames when the
106/// daemon is running a tool loop: one per LLM turn, so clients can render
107/// tool calls as they happen. Only the frame with `is_final = true` marks
108/// the end of the whole generation — clients that want the final aggregated
109/// result (e.g. `collect_stream`) must keep reading until they see it.
110/// Older servers that predate the field will serialize nothing; `serde`'s
111/// default treats missing as `true`, preserving pre-tool-loop semantics.
112#[derive(Serialize, Deserialize, Debug, Clone)]
113pub struct StreamEnd {
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub rid: Option<String>,
116    /// Persisted assistant message id for terminal stream ends.
117    ///
118    /// Present only after the final assistant message has been appended and
119    /// persisted. Intermediate tool-use boundaries and older servers omit it.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub msg_id: Option<String>,
122    /// Durable history revision containing `msg_id`.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub revision: Option<u64>,
125    pub content: String,
126    pub metadata: StreamMetadata,
127    /// Why the model stopped: "end_turn", "tool_use", "max_tokens", etc.
128    #[serde(default, skip_serializing_if = "String::is_empty")]
129    pub finish_reason: String,
130    /// Whether this is the final `StreamEnd` for the generation. Intermediate
131    /// tool-loop boundaries set this to `false`; the terminal StreamEnd sets
132    /// it to `true`. Defaults to `true` so pre-field daemon frames are treated
133    /// as terminal (matching historical single-turn behavior).
134    #[serde(default = "default_true")]
135    pub is_final: bool,
136    /// Sub-agent name when this boundary is from a nested `ask_<name>` loop; see
137    /// [`StreamStart::subagent`]. A sub-agent never emits a terminal
138    /// (`is_final = true`) frame, so a tagged StreamEnd never ends the primary
139    /// generation.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub subagent: Option<String>,
142}
143
144fn default_true() -> bool {
145    true
146}
147
148/// Generation phase change.
149#[derive(Serialize, Deserialize, Debug, Clone)]
150pub struct Phase {
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub rid: Option<String>,
153    pub phase: String,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub model: Option<String>,
156}
157
158// Canonical definition lives in `types` so it can be persisted on `Message`;
159// re-exported here because clients historically import it from `server_msg`.
160pub use crate::types::MessageOrigin;
161
162/// Conversation message appended by the daemon.
163///
164/// The frame's `origin` lives on the flattened [`Message`] (`message.origin`).
165/// Because `message` is flattened, the wire shape is byte-identical to the
166/// envelope-level `origin` field this struct carried historically — only the
167/// Rust-side field moved.
168#[derive(Serialize, Deserialize, Debug, Clone)]
169pub struct NewMessage {
170    #[serde(default)]
171    pub revision: u64,
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub character: Option<String>,
174    #[serde(flatten)]
175    pub message: Message,
176}
177
178/// Tool invoked during generation.
179#[derive(Serialize, Deserialize, Debug, Clone)]
180pub struct ToolCall {
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub rid: Option<String>,
183    pub tool_id: String,
184    pub tool_name: String,
185    pub input: serde_json::Value,
186    /// Sub-agent name when this call is from a nested `ask_<name>` loop; see
187    /// [`StreamStart::subagent`].
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub subagent: Option<String>,
190}
191
192/// Tool completed.
193#[derive(Serialize, Deserialize, Debug, Clone)]
194pub struct ToolResult {
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub rid: Option<String>,
197    pub tool_id: String,
198    pub tool_name: String,
199    pub output: String,
200    #[serde(default)]
201    pub is_error: bool,
202    /// Sub-agent name when this result is from a nested `ask_<name>` loop; see
203    /// [`StreamStart::subagent`].
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub subagent: Option<String>,
206}
207
208/// Server-generated image ready.
209#[derive(Serialize, Deserialize, Debug, Clone)]
210pub struct SendImage {
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub rid: Option<String>,
213    pub path: String,
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub caption: Option<String>,
216    /// Base64-encoded image data for wire transfer.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub data: Option<String>,
219    /// Sub-agent name when this image is from a nested `ask_<name>` loop; see
220    /// [`StreamStart::subagent`].
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub subagent: Option<String>,
223}
224
225/// Unexpected cache invalidation warning.
226#[derive(Serialize, Deserialize, Debug, Clone)]
227pub struct CacheWarning {
228    pub expected_tokens: u32,
229    pub message: String,
230}
231
232/// The daemon rotated from one configured provider key to another mid-request
233/// because the previous key reported a credential-scoped failure (missing,
234/// invalid, exhausted quota or budget, account-scoped rate limit).
235///
236/// Emitted only when the previous key had `warn_on_fallback = true`. The
237/// payload intentionally never carries the env var value or the API key
238/// itself — only the provider key, the friendly key names, the failure
239/// classification, and a sanitized human-readable reason.
240#[derive(Serialize, Deserialize, Debug, Clone)]
241pub struct ProviderFallbackWarning {
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub rid: Option<String>,
244    /// Provider this fallback applies to (e.g. `"openrouter"`).
245    pub provider: String,
246    /// Friendly name of the key being abandoned.
247    pub from_key: String,
248    /// Friendly name of the key now in use.
249    pub to_key: String,
250    /// Stable failure tag from `CredentialFailureKind::as_str()`. Stable
251    /// across releases so client-side rendering can branch on it.
252    pub kind: String,
253    /// HTTP status when the failure was a status-shaped error; `None` for
254    /// missing-key / network / classified-by-body cases.
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub status: Option<u16>,
257    /// Sanitized human-readable summary. Never contains secrets.
258    pub message: String,
259}
260
261/// A configured usage budget crossed one or more warning thresholds.
262#[derive(Serialize, Deserialize, Debug, Clone)]
263pub struct UsageWarning {
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub rid: Option<String>,
266    /// Configured budget name.
267    pub budget: String,
268    /// Human-readable warning text.
269    pub message: String,
270    /// Current spend for the budget window.
271    pub current_cost: f64,
272    /// Configured budget limit.
273    pub cost_limit: f64,
274    /// Fraction used, e.g. 0.8 for 80%.
275    pub percent_used: f64,
276    /// Newly crossed warning thresholds, as fractions.
277    pub crossed_warn_at: Vec<f64>,
278    /// Calendar period name.
279    pub period: String,
280    /// RFC3339 period start.
281    pub period_start: String,
282    /// RFC3339 reset/end time.
283    pub reset_at: String,
284    /// `reset_at` rendered in the daemon's local time as `YYYY-MM-DD HH:MM AM|PM`.
285    /// Clients that surface this string verbatim should prefer it over `reset_at`
286    /// (which is UTC); the structured `reset_at` stays for machine consumers.
287    #[serde(default)]
288    pub reset_at_display: String,
289}
290
291/// All server → client message types, tagged by "type".
292#[derive(Serialize, Deserialize, Debug, Clone)]
293#[serde(tag = "type", rename_all = "snake_case")]
294pub enum ServerMessage {
295    Hello(ServerHello),
296    History(History),
297    Shutdown(Shutdown),
298    Ping(Ping),
299    CommandOutput(CommandOutput),
300    Error(Error),
301    StreamStart(StreamStart),
302    StreamChunk(StreamChunk),
303    StreamEnd(StreamEnd),
304    Phase(Phase),
305    NewMessage(NewMessage),
306    ToolCall(ToolCall),
307    ToolResult(ToolResult),
308    SendImage(SendImage),
309    CacheWarning(CacheWarning),
310    ProviderFallbackWarning(ProviderFallbackWarning),
311    UsageWarning(UsageWarning),
312    /// A frame whose `type` tag matches no known variant. Produced only by
313    /// deserialization (never constructed or sent by the server), so a client
314    /// built against an older protocol skips message types added by a newer
315    /// daemon instead of erroring the connection. Clients should treat this as
316    /// a benign no-op. Re-serializing it is lossy and must be avoided.
317    #[serde(other)]
318    Unknown,
319}
320
321impl ServerMessage {
322    /// Attach a request ID to request-scoped responses.
323    ///
324    /// Unsolicited push/broadcast messages intentionally ignore `rid`.
325    #[must_use]
326    pub fn with_rid(mut self, rid: Option<String>) -> Self {
327        // Exactly one arm runs per call, so `rid` is moved into the matched
328        // field rather than cloned per variant.
329        match &mut self {
330            ServerMessage::History(msg) => msg.rid = rid,
331            ServerMessage::CommandOutput(msg) => msg.rid = rid,
332            ServerMessage::Error(msg) => msg.rid = rid,
333            ServerMessage::StreamStart(msg) => msg.rid = rid,
334            ServerMessage::StreamChunk(msg) => msg.rid = rid,
335            ServerMessage::StreamEnd(msg) => msg.rid = rid,
336            ServerMessage::Phase(msg) => msg.rid = rid,
337            ServerMessage::ToolCall(msg) => msg.rid = rid,
338            ServerMessage::ToolResult(msg) => msg.rid = rid,
339            ServerMessage::SendImage(msg) => msg.rid = rid,
340            ServerMessage::ProviderFallbackWarning(msg) => msg.rid = rid,
341            ServerMessage::UsageWarning(msg) => msg.rid = rid,
342            ServerMessage::Hello(_)
343            | ServerMessage::Shutdown(_)
344            | ServerMessage::Ping(_)
345            | ServerMessage::NewMessage(_)
346            | ServerMessage::CacheWarning(_)
347            | ServerMessage::Unknown => {}
348        }
349        self
350    }
351
352    /// The sub-agent name tagged on this frame, if any. `None` is primary-model
353    /// (or non-stream) activity. Lets clients bracket nested `ask_<name>` output
354    /// by watching the tag transition on/off.
355    #[must_use]
356    pub fn subagent(&self) -> Option<&str> {
357        match self {
358            ServerMessage::StreamStart(m) => m.subagent.as_deref(),
359            ServerMessage::StreamChunk(m) => m.subagent.as_deref(),
360            ServerMessage::StreamEnd(m) => m.subagent.as_deref(),
361            ServerMessage::ToolCall(m) => m.subagent.as_deref(),
362            ServerMessage::ToolResult(m) => m.subagent.as_deref(),
363            ServerMessage::SendImage(m) => m.subagent.as_deref(),
364            ServerMessage::Hello(_)
365            | ServerMessage::History(_)
366            | ServerMessage::Shutdown(_)
367            | ServerMessage::Ping(_)
368            | ServerMessage::CommandOutput(_)
369            | ServerMessage::Error(_)
370            | ServerMessage::Phase(_)
371            | ServerMessage::NewMessage(_)
372            | ServerMessage::CacheWarning(_)
373            | ServerMessage::ProviderFallbackWarning(_)
374            | ServerMessage::UsageWarning(_)
375            | ServerMessage::Unknown => None,
376        }
377    }
378
379    /// Tag a stream/tool frame as belonging to a sub-agent's nested loop.
380    ///
381    /// Used by the sub-agent forwarder to attribute the messages it relays from
382    /// an `ask_<name>` loop, so clients render them as nested activity. Frame
383    /// types that a sub-agent loop never emits are left untouched.
384    pub fn set_subagent(&mut self, name: &str) {
385        let tag = || Some(name.to_owned());
386        match self {
387            ServerMessage::StreamStart(msg) => msg.subagent = tag(),
388            ServerMessage::StreamChunk(msg) => msg.subagent = tag(),
389            ServerMessage::StreamEnd(msg) => msg.subagent = tag(),
390            ServerMessage::ToolCall(msg) => msg.subagent = tag(),
391            ServerMessage::ToolResult(msg) => msg.subagent = tag(),
392            ServerMessage::SendImage(msg) => msg.subagent = tag(),
393            ServerMessage::Hello(_)
394            | ServerMessage::History(_)
395            | ServerMessage::Shutdown(_)
396            | ServerMessage::Ping(_)
397            | ServerMessage::CommandOutput(_)
398            | ServerMessage::Error(_)
399            | ServerMessage::Phase(_)
400            | ServerMessage::NewMessage(_)
401            | ServerMessage::CacheWarning(_)
402            | ServerMessage::ProviderFallbackWarning(_)
403            | ServerMessage::UsageWarning(_)
404            | ServerMessage::Unknown => {}
405        }
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    #[test]
414    fn unknown_type_deserializes_to_unknown_variant() {
415        // A frame type a future daemon adds must not fail deserialization on an
416        // older client; it lands in `Unknown` so the client can skip it instead
417        // of erroring the connection.
418        let json = r#"{"type":"some_future_message","field":42}"#;
419        let msg: ServerMessage = serde_json::from_str(json).expect("must not error");
420        assert!(matches!(msg, ServerMessage::Unknown));
421    }
422
423    #[test]
424    fn known_types_still_deserialize_to_their_variant() {
425        // The catch-all must not shadow real variants.
426        let json = r#"{"type":"ping"}"#;
427        let msg: ServerMessage = serde_json::from_str(json).expect("ping must parse");
428        assert!(matches!(msg, ServerMessage::Ping(_)));
429    }
430
431    #[test]
432    fn set_subagent_tags_stream_and_tool_frames() {
433        let mut chunk = ServerMessage::StreamChunk(StreamChunk {
434            rid: None,
435            text: "hi".into(),
436            content_type: "text".into(),
437            subagent: None,
438        });
439        chunk.set_subagent("research");
440        assert_eq!(chunk.subagent(), Some("research"));
441
442        // A frame type a sub-agent loop never emits is left untouched.
443        let mut phase = ServerMessage::Phase(Phase {
444            rid: None,
445            phase: "thinking".into(),
446            model: None,
447        });
448        phase.set_subagent("research");
449        assert_eq!(phase.subagent(), None);
450    }
451
452    #[test]
453    fn subagent_tag_survives_wire_round_trip() {
454        let mut call = ServerMessage::ToolCall(ToolCall {
455            rid: None,
456            tool_id: "t1".into(),
457            tool_name: "search".into(),
458            input: serde_json::json!({}),
459            subagent: None,
460        });
461        call.set_subagent("research");
462        let wire = serde_json::to_string(&call).unwrap();
463        assert!(wire.contains("\"subagent\":\"research\""), "wire: {wire}");
464        let back: ServerMessage = serde_json::from_str(&wire).unwrap();
465        assert_eq!(back.subagent(), Some("research"));
466    }
467
468    #[test]
469    fn untagged_frame_omits_subagent_on_the_wire() {
470        // `skip_serializing_if` keeps the field off the wire for primary frames,
471        // so the cache prefix and existing-client parsing stay unchanged.
472        let call = ServerMessage::ToolCall(ToolCall {
473            rid: None,
474            tool_id: "t1".into(),
475            tool_name: "search".into(),
476            input: serde_json::json!({}),
477            subagent: None,
478        });
479        let wire = serde_json::to_string(&call).unwrap();
480        assert!(!wire.contains("subagent"), "wire: {wire}");
481    }
482}