Skip to main content

shore_protocol/
types.rs

1use serde::{Deserialize, Serialize};
2
3/// Role of a message participant.
4#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
5#[serde(rename_all = "snake_case")]
6pub enum Role {
7    User,
8    Assistant,
9    System,
10}
11
12/// Reference to an image file.
13#[derive(Serialize, Deserialize, Debug, Clone)]
14pub struct ImageRef {
15    pub path: String,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub caption: Option<String>,
18    /// Base64-encoded image data for wire transfer. Stripped on disk storage.
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub data: Option<String>,
21}
22
23impl PartialEq for ImageRef {
24    fn eq(&self, other: &Self) -> bool {
25        self.path == other.path && self.caption == other.caption
26    }
27}
28
29/// A structured content block within a message.
30///
31/// Messages can contain a sequence of content blocks representing text,
32/// thinking/reasoning, tool invocations, and tool results. This preserves
33/// the full fidelity of what happened during generation.
34#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
35#[serde(tag = "type", rename_all = "snake_case")]
36pub enum ContentBlock {
37    Text {
38        text: String,
39    },
40    Thinking {
41        thinking: String,
42        #[serde(default, skip_serializing_if = "Option::is_none")]
43        signature: Option<String>,
44    },
45    ToolUse {
46        id: String,
47        name: String,
48        input: serde_json::Value,
49    },
50    RedactedThinking {
51        data: String,
52    },
53    ToolResult {
54        tool_use_id: String,
55        content: String,
56        #[serde(default)]
57        is_error: bool,
58    },
59}
60
61/// How a message entered the conversation.
62///
63/// Persisted on [`Message`] (and echoed on `NewMessage` pushes) so that
64/// history consumers can distinguish autonomous (heartbeat-initiated)
65/// assistant messages from replies. `None` on a stored message means the
66/// origin was not recorded (messages persisted before origin tracking, or
67/// ordinary user/assistant turns where the role already implies it).
68#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
69#[serde(rename_all = "snake_case")]
70pub enum MessageOrigin {
71    UserInput,
72    AssistantReply,
73    Autonomous,
74}
75
76/// A chat message. One shape everywhere — no polymorphism.
77///
78/// `content_blocks` is the canonical content representation.
79/// `content` is a derived convenience field (human-readable text summary).
80/// On disk, only `content_blocks` is stored; `content` is derived on load.
81#[derive(Serialize, Deserialize, Debug, Clone)]
82pub struct Message {
83    pub msg_id: String,
84    pub role: Role,
85    #[serde(default)]
86    pub content: String,
87    #[serde(default)]
88    pub images: Vec<ImageRef>,
89    #[serde(default)]
90    pub content_blocks: Vec<ContentBlock>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub alt_index: Option<u32>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub alt_count: Option<u32>,
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub alternatives: Vec<MessageAlternative>,
97    pub timestamp: String,
98    /// Provider key that minted this message's content (e.g. `"anthropic"`,
99    /// `"openrouter-anthropic"`). Opaque thinking data — `thinking`
100    /// signatures and `redacted_thinking` blobs — is bound to its minting
101    /// provider and is not portable across a provider switch; the replay path
102    /// uses this to drop blocks the active provider cannot interpret. `None`
103    /// for messages persisted before provenance tracking, or for messages
104    /// (user turns, system recaps) that carry no provider-bound data.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub provider_key: Option<String>,
107    /// Model id that minted this message's content (e.g. `"claude-opus-4-6"`,
108    /// `"anthropic/claude-opus-4.6"`), in the same vocabulary as the usage
109    /// ledger. Unlike [`Message::provider_key`] (which drives thinking-replay
110    /// portability), this is pure provenance: it lets history tooling
111    /// attribute a stored turn to the model that generated it. `None` for
112    /// messages persisted before model provenance tracking and for messages
113    /// no model minted (user turns, system recaps).
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub model: Option<String>,
116    /// How this message entered the conversation. Currently only
117    /// `Some(Autonomous)` is persisted (heartbeat `<sendMessage>` output);
118    /// compaction's deep-idle archive uses it to keep unanswered autonomous
119    /// messages visible across an archive boundary.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub origin: Option<MessageOrigin>,
122}
123
124/// Stored alternate body for a regenerated assistant message.
125///
126/// `Message` keeps the currently selected alternative in its top-level
127/// `content`/`content_blocks` fields so existing clients and prompt assembly
128/// keep reading the active response. `alternatives` stores every selectable
129/// candidate, including the active one.
130#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
131pub struct MessageAlternative {
132    #[serde(default)]
133    pub content: String,
134    #[serde(default)]
135    pub images: Vec<ImageRef>,
136    #[serde(default)]
137    pub content_blocks: Vec<ContentBlock>,
138    #[serde(default)]
139    pub timestamp: String,
140    /// Provider key that minted this alternative's content. A regenerated
141    /// assistant body can be produced under a different provider than the
142    /// original message or a sibling alternative, so each alternative carries
143    /// its own provenance rather than inheriting the single
144    /// [`Message::provider_key`]. The replay portability filter uses this to
145    /// drop opaque thinking data the active provider cannot interpret. `None`
146    /// for alternatives persisted before per-alternative provenance tracking,
147    /// in which case callers fall back to [`Message::provider_key`].
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub provider_key: Option<String>,
150    /// Model id that minted this alternative's content. Like
151    /// [`MessageAlternative::provider_key`], each alternative carries its own
152    /// provenance because regenerated bodies can come from different models.
153    /// `None` for alternatives persisted before model provenance tracking, in
154    /// which case callers fall back to [`Message::model`].
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub model: Option<String>,
157}
158
159impl MessageAlternative {
160    /// Ensure `content` and `content_blocks` are consistent after
161    /// deserialization, matching [`Message::normalize`].
162    pub fn normalize(&mut self) {
163        if self.content_blocks.is_empty() && !self.content.is_empty() {
164            self.content_blocks = vec![ContentBlock::Text {
165                text: self.content.clone(),
166            }];
167        } else if !self.content_blocks.is_empty() {
168            self.content = derive_content_from_blocks(&self.content_blocks);
169        } else {
170            // Both empty: nothing to reconcile.
171        }
172    }
173}
174
175impl Message {
176    /// Ensure `content` and `content_blocks` are consistent after deserialization.
177    ///
178    /// Handles both old format (content only) and new format (content_blocks only):
179    /// - Old: wraps `content` in a `Text` block
180    /// - New: derives `content` from blocks
181    pub fn normalize(&mut self) {
182        if self.content_blocks.is_empty() && !self.content.is_empty() {
183            // Legacy format: content present but no blocks.
184            self.content_blocks = vec![ContentBlock::Text {
185                text: self.content.clone(),
186            }];
187        } else if !self.content_blocks.is_empty() {
188            // Canonical: derive content from blocks.
189            self.content = derive_content_from_blocks(&self.content_blocks);
190        } else {
191            // Both empty: nothing to reconcile.
192        }
193
194        for alt in &mut self.alternatives {
195            alt.normalize();
196        }
197        if !self.alternatives.is_empty() {
198            let count = u32::try_from(self.alternatives.len()).unwrap_or(u32::MAX);
199            self.alt_count = Some(count);
200            let index = self.alt_index.unwrap_or(count.saturating_sub(1));
201            self.alt_index = Some(index.min(count.saturating_sub(1)));
202        }
203    }
204
205    /// True when this is a user-role message whose content consists
206    /// entirely of `ToolResult` blocks — i.e. a synthetic tool-loop
207    /// message rather than a real user turn.
208    ///
209    /// Used by compaction, history rendering, and turn-counting logic.
210    pub fn is_tool_result_only(&self) -> bool {
211        if self.role != Role::User {
212            return false;
213        }
214        !self.content_blocks.is_empty()
215            && self
216                .content_blocks
217                .iter()
218                .all(|b| matches!(b, ContentBlock::ToolResult { .. }))
219    }
220
221    /// Serialize for disk storage, omitting the redundant `content` field
222    /// and stripping inline `data` from image refs.
223    ///
224    /// The wire protocol (History, log command) still includes `content` via
225    /// normal serde serialization. This method is only for JSONL persistence.
226    pub fn serialize_for_storage(&self) -> Result<String, serde_json::Error> {
227        let mut val = serde_json::to_value(self)?;
228        if let Some(obj) = val.as_object_mut() {
229            let _ignored = obj.remove("content");
230
231            // Strip inline image data — storage uses paths, not embedded bytes.
232            let strip_image_data = |images: Option<&mut serde_json::Value>| {
233                if let Some(arr) = images.and_then(|v| v.as_array_mut()) {
234                    for img in arr {
235                        if let Some(img_obj) = img.as_object_mut() {
236                            let _removed = img_obj.remove("data");
237                        }
238                    }
239                }
240            };
241
242            strip_image_data(obj.get_mut("images"));
243
244            // `alternatives` (regenerated responses) carry their own image refs,
245            // which must be stripped too or they persist base64 blobs to disk.
246            if let Some(alternatives) = obj.get_mut("alternatives").and_then(|v| v.as_array_mut()) {
247                for alternative in alternatives {
248                    if let Some(alt_obj) = alternative.as_object_mut() {
249                        strip_image_data(alt_obj.get_mut("images"));
250                    }
251                }
252            }
253        }
254        serde_json::to_string(&val)
255    }
256}
257
258/// Token usage counts from a generation.
259#[derive(Serialize, Deserialize, Debug, Clone)]
260pub struct TokenCounts {
261    pub input: u64,
262    pub output: u64,
263    pub cache_read: u64,
264    pub cache_write: u64,
265}
266
267/// Timing information for a generation.
268#[derive(Serialize, Deserialize, Debug, Clone)]
269pub struct TimingInfo {
270    pub total_ms: u32,
271    pub ttft_ms: u32,
272}
273
274/// Metadata attached to stream_end.
275#[derive(Serialize, Deserialize, Debug, Clone)]
276pub struct StreamMetadata {
277    pub tokens: TokenCounts,
278    pub timing: TimingInfo,
279    pub model: String,
280}
281
282/// Derive a human-readable text summary from content blocks.
283///
284/// Joins all `Text` block contents (trimmed), and optionally `ToolResult`
285/// contents, skipping thinking, redacted thinking, and tool use blocks
286/// which are not user-visible text.
287///
288/// When `include_tool_results` is true, this is the canonical way to produce
289/// `Message.content`. When false, only `Text` blocks contribute (used for
290/// merged messages where tool results are already embedded in content_blocks).
291pub fn derive_content_from_blocks_with(
292    blocks: &[ContentBlock],
293    include_tool_results: bool,
294) -> String {
295    let mut parts: Vec<&str> = Vec::new();
296
297    for block in blocks {
298        match block {
299            ContentBlock::Text { text } => {
300                let trimmed = text.trim();
301                if !trimmed.is_empty() {
302                    parts.push(trimmed);
303                }
304            }
305            ContentBlock::ToolResult { content, .. } if include_tool_results => {
306                let trimmed = content.trim();
307                if !trimmed.is_empty() {
308                    parts.push(trimmed);
309                }
310            }
311            ContentBlock::Thinking { .. }
312            | ContentBlock::ToolUse { .. }
313            | ContentBlock::RedactedThinking { .. }
314            | ContentBlock::ToolResult { .. } => {}
315        }
316    }
317
318    parts.join("\n")
319}
320
321/// Derive a human-readable text summary from content blocks (including tool results).
322pub fn derive_content_from_blocks(blocks: &[ContentBlock]) -> String {
323    derive_content_from_blocks_with(blocks, true)
324}
325
326/// Base64-encoded character avatar for clients that cannot read the daemon's
327/// local config filesystem.
328#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
329pub struct CharacterAvatar {
330    pub mime_type: String,
331    pub data: String,
332}
333
334/// Information about a character.
335#[derive(Serialize, Deserialize, Debug, Clone)]
336pub struct CharacterInfo {
337    pub name: String,
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub avatar: Option<CharacterAvatar>,
340}
341
342impl CharacterInfo {
343    pub fn new<N: Into<String>>(name: N) -> Self {
344        Self {
345            name: name.into(),
346            avatar: None,
347        }
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    fn field<'val>(value: &'val serde_json::Value, key: &str) -> &'val serde_json::Value {
356        value.get(key).expect("expected JSON field")
357    }
358
359    fn item<T>(items: &[T], index: usize) -> &T {
360        items.get(index).expect("expected item")
361    }
362
363    #[test]
364    fn derive_content_empty_blocks() {
365        assert_eq!(derive_content_from_blocks(&[]), "");
366    }
367
368    #[test]
369    fn derive_content_text_only() {
370        let blocks = vec![ContentBlock::Text {
371            text: "hello world".into(),
372        }];
373        assert_eq!(derive_content_from_blocks(&blocks), "hello world");
374    }
375
376    #[test]
377    fn derive_content_trims_whitespace() {
378        let blocks = vec![ContentBlock::Text {
379            text: "\n\n".into(),
380        }];
381        assert_eq!(derive_content_from_blocks(&blocks), "");
382    }
383
384    #[test]
385    fn derive_content_tool_result() {
386        let blocks = vec![ContentBlock::ToolResult {
387            tool_use_id: "t1".into(),
388            content: "2026-03-29T10:00:00Z".into(),
389            is_error: false,
390        }];
391        assert_eq!(derive_content_from_blocks(&blocks), "2026-03-29T10:00:00Z");
392    }
393
394    #[test]
395    fn derive_content_skips_thinking_and_tool_use() {
396        let blocks = vec![
397            ContentBlock::Thinking {
398                thinking: "Let me think...".into(),
399                signature: None,
400            },
401            ContentBlock::ToolUse {
402                id: "t1".into(),
403                name: "check_time".into(),
404                input: serde_json::json!({}),
405            },
406            ContentBlock::RedactedThinking {
407                data: "opaque".into(),
408            },
409            ContentBlock::Text {
410                text: "The answer".into(),
411            },
412        ];
413        assert_eq!(derive_content_from_blocks(&blocks), "The answer");
414    }
415
416    #[test]
417    fn derive_content_multiple_text_blocks() {
418        let blocks = vec![
419            ContentBlock::Text {
420                text: "first".into(),
421            },
422            ContentBlock::Text {
423                text: "second".into(),
424            },
425        ];
426        assert_eq!(derive_content_from_blocks(&blocks), "first\nsecond");
427    }
428
429    // ── normalize() ──────────────────────────────────────────────────
430
431    fn make_msg(content: &str, blocks: Vec<ContentBlock>) -> Message {
432        Message {
433            msg_id: "m1".into(),
434            origin: None,
435            role: Role::User,
436            content: content.into(),
437            images: vec![],
438            content_blocks: blocks,
439            alt_index: None,
440            alt_count: None,
441            alternatives: vec![],
442            provider_key: None,
443            model: None,
444            timestamp: "2026-01-01T00:00:00Z".into(),
445        }
446    }
447
448    #[test]
449    fn normalize_legacy_wraps_content_in_text_block() {
450        let mut msg = make_msg("hello world", vec![]);
451        msg.normalize();
452        assert_eq!(msg.content_blocks.len(), 1);
453        assert!(
454            matches!(item(&msg.content_blocks, 0), ContentBlock::Text { text } if text == "hello world")
455        );
456        assert_eq!(msg.content, "hello world");
457    }
458
459    #[test]
460    fn normalize_canonical_derives_content_from_blocks() {
461        let mut msg = make_msg(
462            "",
463            vec![ContentBlock::Text {
464                text: "derived".into(),
465            }],
466        );
467        msg.normalize();
468        assert_eq!(msg.content, "derived");
469        assert_eq!(msg.content_blocks.len(), 1);
470    }
471
472    #[test]
473    fn normalize_both_empty_is_noop() {
474        let mut msg = make_msg("", vec![]);
475        msg.normalize();
476        assert_eq!(msg.content, "");
477        assert!(msg.content_blocks.is_empty());
478    }
479
480    // ── serialize_for_storage() ─────────────────────────────────────
481
482    #[test]
483    fn serialize_for_storage_omits_content_field() {
484        let msg = make_msg(
485            "should be removed",
486            vec![ContentBlock::Text {
487                text: "canonical".into(),
488            }],
489        );
490        let json_str = msg.serialize_for_storage().unwrap();
491        let val: serde_json::Value = serde_json::from_str(&json_str).unwrap();
492        assert!(
493            val.get("content").is_none(),
494            "content field should be omitted"
495        );
496        assert!(val.get("content_blocks").is_some());
497    }
498
499    #[test]
500    fn serialize_for_storage_roundtrips_other_fields() {
501        let msg = make_msg(
502            "ignored",
503            vec![ContentBlock::Text {
504                text: "hello".into(),
505            }],
506        );
507        let json_str = msg.serialize_for_storage().unwrap();
508        let val: serde_json::Value = serde_json::from_str(&json_str).unwrap();
509        assert_eq!(field(&val, "msg_id"), "m1");
510        assert_eq!(field(&val, "role"), "user");
511        assert_eq!(field(&val, "timestamp"), "2026-01-01T00:00:00Z");
512    }
513
514    #[test]
515    fn serialize_for_storage_strips_inline_image_data_everywhere() {
516        let mut msg = make_msg(
517            "ignored",
518            vec![ContentBlock::Text {
519                text: "active".into(),
520            }],
521        );
522        msg.images = vec![ImageRef {
523            path: "/img/top.png".into(),
524            caption: None,
525            data: Some("TOPDATA".into()),
526        }];
527        msg.alternatives = vec![MessageAlternative {
528            content: "alt".into(),
529            images: vec![ImageRef {
530                path: "/img/alt.png".into(),
531                caption: None,
532                data: Some("ALTDATA".into()),
533            }],
534            content_blocks: vec![],
535            timestamp: "2026-01-01T00:00:00Z".into(),
536            provider_key: None,
537            model: None,
538        }];
539
540        let json_str = msg.serialize_for_storage().unwrap();
541        assert!(
542            !json_str.contains("TOPDATA"),
543            "top-level image data must be stripped"
544        );
545        assert!(
546            !json_str.contains("ALTDATA"),
547            "alternative image data must be stripped"
548        );
549        // Image paths are retained — storage references files by path.
550        assert!(json_str.contains("/img/alt.png"));
551    }
552
553    // ── derive_content_from_blocks_with ─────────────────────────────
554
555    #[test]
556    fn derive_content_excludes_tool_results_when_flag_false() {
557        let blocks = vec![
558            ContentBlock::Text {
559                text: "hello".into(),
560            },
561            ContentBlock::ToolResult {
562                tool_use_id: "t1".into(),
563                content: "result".into(),
564                is_error: false,
565            },
566        ];
567        assert_eq!(derive_content_from_blocks_with(&blocks, false), "hello");
568        assert_eq!(
569            derive_content_from_blocks_with(&blocks, true),
570            "hello\nresult"
571        );
572    }
573
574    #[test]
575    fn derive_content_mixed_text_and_tool_result() {
576        let blocks = vec![
577            ContentBlock::ToolResult {
578                tool_use_id: "t1".into(),
579                content: "tool output".into(),
580                is_error: false,
581            },
582            ContentBlock::ToolResult {
583                tool_use_id: "t2".into(),
584                content: "more output".into(),
585                is_error: false,
586            },
587        ];
588        assert_eq!(
589            derive_content_from_blocks(&blocks),
590            "tool output\nmore output"
591        );
592    }
593}