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/// A chat message. One shape everywhere — no polymorphism.
62///
63/// `content_blocks` is the canonical content representation.
64/// `content` is a derived convenience field (human-readable text summary).
65/// On disk, only `content_blocks` is stored; `content` is derived on load.
66#[derive(Serialize, Deserialize, Debug, Clone)]
67pub struct Message {
68    pub msg_id: String,
69    pub role: Role,
70    #[serde(default)]
71    pub content: String,
72    #[serde(default)]
73    pub images: Vec<ImageRef>,
74    #[serde(default)]
75    pub content_blocks: Vec<ContentBlock>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub alt_index: Option<u32>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub alt_count: Option<u32>,
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    pub alternatives: Vec<MessageAlternative>,
82    pub timestamp: String,
83}
84
85/// Stored alternate body for a regenerated assistant message.
86///
87/// `Message` keeps the currently selected alternative in its top-level
88/// `content`/`content_blocks` fields so existing clients and prompt assembly
89/// keep reading the active response. `alternatives` stores every selectable
90/// candidate, including the active one.
91#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
92pub struct MessageAlternative {
93    #[serde(default)]
94    pub content: String,
95    #[serde(default)]
96    pub images: Vec<ImageRef>,
97    #[serde(default)]
98    pub content_blocks: Vec<ContentBlock>,
99    #[serde(default)]
100    pub timestamp: String,
101}
102
103impl MessageAlternative {
104    /// Ensure `content` and `content_blocks` are consistent after
105    /// deserialization, matching [`Message::normalize`].
106    pub fn normalize(&mut self) {
107        if self.content_blocks.is_empty() && !self.content.is_empty() {
108            self.content_blocks = vec![ContentBlock::Text {
109                text: self.content.clone(),
110            }];
111        } else if !self.content_blocks.is_empty() {
112            self.content = derive_content_from_blocks(&self.content_blocks);
113        }
114    }
115}
116
117impl Message {
118    /// Ensure `content` and `content_blocks` are consistent after deserialization.
119    ///
120    /// Handles both old format (content only) and new format (content_blocks only):
121    /// - Old: wraps `content` in a `Text` block
122    /// - New: derives `content` from blocks
123    pub fn normalize(&mut self) {
124        if self.content_blocks.is_empty() && !self.content.is_empty() {
125            // Legacy format: content present but no blocks.
126            self.content_blocks = vec![ContentBlock::Text {
127                text: self.content.clone(),
128            }];
129        } else if !self.content_blocks.is_empty() {
130            // Canonical: derive content from blocks.
131            self.content = derive_content_from_blocks(&self.content_blocks);
132        }
133
134        for alt in &mut self.alternatives {
135            alt.normalize();
136        }
137        if !self.alternatives.is_empty() {
138            let count = self.alternatives.len() as u32;
139            self.alt_count = Some(count);
140            let index = self.alt_index.unwrap_or(count.saturating_sub(1));
141            self.alt_index = Some(index.min(count.saturating_sub(1)));
142        }
143    }
144
145    /// True when this is a user-role message whose content consists
146    /// entirely of `ToolResult` blocks — i.e. a synthetic tool-loop
147    /// message rather than a real user turn.
148    ///
149    /// Used by compaction, history rendering, and turn-counting logic.
150    pub fn is_tool_result_only(&self) -> bool {
151        if self.role != Role::User {
152            return false;
153        }
154        !self.content_blocks.is_empty()
155            && self
156                .content_blocks
157                .iter()
158                .all(|b| matches!(b, ContentBlock::ToolResult { .. }))
159    }
160
161    /// Serialize for disk storage, omitting the redundant `content` field
162    /// and stripping inline `data` from image refs.
163    ///
164    /// The wire protocol (History, log command) still includes `content` via
165    /// normal serde serialization. This method is only for JSONL persistence.
166    pub fn serialize_for_storage(&self) -> Result<String, serde_json::Error> {
167        let mut val = serde_json::to_value(self)?;
168        if let Some(obj) = val.as_object_mut() {
169            obj.remove("content");
170            // Strip inline image data — storage uses paths, not embedded bytes.
171            if let Some(images) = obj.get_mut("images").and_then(|v| v.as_array_mut()) {
172                for img in images {
173                    if let Some(obj) = img.as_object_mut() {
174                        obj.remove("data");
175                    }
176                }
177            }
178        }
179        serde_json::to_string(&val)
180    }
181}
182
183/// Token usage counts from a generation.
184#[derive(Serialize, Deserialize, Debug, Clone)]
185pub struct TokenCounts {
186    pub input: u32,
187    pub output: u32,
188    pub cache_read: u32,
189    pub cache_write: u32,
190}
191
192/// Timing information for a generation.
193#[derive(Serialize, Deserialize, Debug, Clone)]
194pub struct TimingInfo {
195    pub total_ms: u32,
196    pub ttft_ms: u32,
197}
198
199/// Metadata attached to stream_end.
200#[derive(Serialize, Deserialize, Debug, Clone)]
201pub struct StreamMetadata {
202    pub tokens: TokenCounts,
203    pub timing: TimingInfo,
204    pub model: String,
205}
206
207/// Derive a human-readable text summary from content blocks.
208///
209/// Joins all `Text` block contents (trimmed), and optionally `ToolResult`
210/// contents, skipping thinking, redacted thinking, and tool use blocks
211/// which are not user-visible text.
212///
213/// When `include_tool_results` is true, this is the canonical way to produce
214/// `Message.content`. When false, only `Text` blocks contribute (used for
215/// merged messages where tool results are already embedded in content_blocks).
216pub fn derive_content_from_blocks_with(
217    blocks: &[ContentBlock],
218    include_tool_results: bool,
219) -> String {
220    let mut parts: Vec<&str> = Vec::new();
221
222    for block in blocks {
223        match block {
224            ContentBlock::Text { text } => {
225                let trimmed = text.trim();
226                if !trimmed.is_empty() {
227                    parts.push(trimmed);
228                }
229            }
230            ContentBlock::ToolResult { content, .. } if include_tool_results => {
231                let trimmed = content.trim();
232                if !trimmed.is_empty() {
233                    parts.push(trimmed);
234                }
235            }
236            _ => {}
237        }
238    }
239
240    parts.join("\n")
241}
242
243/// Derive a human-readable text summary from content blocks (including tool results).
244pub fn derive_content_from_blocks(blocks: &[ContentBlock]) -> String {
245    derive_content_from_blocks_with(blocks, true)
246}
247
248/// Base64-encoded character avatar for clients that cannot read the daemon's
249/// local config filesystem.
250#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
251pub struct CharacterAvatar {
252    pub mime_type: String,
253    pub data: String,
254}
255
256/// Information about a character.
257#[derive(Serialize, Deserialize, Debug, Clone)]
258pub struct CharacterInfo {
259    pub name: String,
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub avatar: Option<CharacterAvatar>,
262}
263
264impl CharacterInfo {
265    pub fn new(name: impl Into<String>) -> Self {
266        Self {
267            name: name.into(),
268            avatar: None,
269        }
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn derive_content_empty_blocks() {
279        assert_eq!(derive_content_from_blocks(&[]), "");
280    }
281
282    #[test]
283    fn derive_content_text_only() {
284        let blocks = vec![ContentBlock::Text {
285            text: "hello world".into(),
286        }];
287        assert_eq!(derive_content_from_blocks(&blocks), "hello world");
288    }
289
290    #[test]
291    fn derive_content_trims_whitespace() {
292        let blocks = vec![ContentBlock::Text {
293            text: "\n\n".into(),
294        }];
295        assert_eq!(derive_content_from_blocks(&blocks), "");
296    }
297
298    #[test]
299    fn derive_content_tool_result() {
300        let blocks = vec![ContentBlock::ToolResult {
301            tool_use_id: "t1".into(),
302            content: "2026-03-29T10:00:00Z".into(),
303            is_error: false,
304        }];
305        assert_eq!(derive_content_from_blocks(&blocks), "2026-03-29T10:00:00Z");
306    }
307
308    #[test]
309    fn derive_content_skips_thinking_and_tool_use() {
310        let blocks = vec![
311            ContentBlock::Thinking {
312                thinking: "Let me think...".into(),
313                signature: None,
314            },
315            ContentBlock::ToolUse {
316                id: "t1".into(),
317                name: "check_time".into(),
318                input: serde_json::json!({}),
319            },
320            ContentBlock::RedactedThinking {
321                data: "opaque".into(),
322            },
323            ContentBlock::Text {
324                text: "The answer".into(),
325            },
326        ];
327        assert_eq!(derive_content_from_blocks(&blocks), "The answer");
328    }
329
330    #[test]
331    fn derive_content_multiple_text_blocks() {
332        let blocks = vec![
333            ContentBlock::Text {
334                text: "first".into(),
335            },
336            ContentBlock::Text {
337                text: "second".into(),
338            },
339        ];
340        assert_eq!(derive_content_from_blocks(&blocks), "first\nsecond");
341    }
342
343    // ── normalize() ──────────────────────────────────────────────────
344
345    fn make_msg(content: &str, blocks: Vec<ContentBlock>) -> Message {
346        Message {
347            msg_id: "m1".into(),
348            role: Role::User,
349            content: content.into(),
350            images: vec![],
351            content_blocks: blocks,
352            alt_index: None,
353            alt_count: None,
354            alternatives: vec![],
355            timestamp: "2026-01-01T00:00:00Z".into(),
356        }
357    }
358
359    #[test]
360    fn normalize_legacy_wraps_content_in_text_block() {
361        let mut msg = make_msg("hello world", vec![]);
362        msg.normalize();
363        assert_eq!(msg.content_blocks.len(), 1);
364        assert!(
365            matches!(&msg.content_blocks[0], ContentBlock::Text { text } if text == "hello world")
366        );
367        assert_eq!(msg.content, "hello world");
368    }
369
370    #[test]
371    fn normalize_canonical_derives_content_from_blocks() {
372        let mut msg = make_msg(
373            "",
374            vec![ContentBlock::Text {
375                text: "derived".into(),
376            }],
377        );
378        msg.normalize();
379        assert_eq!(msg.content, "derived");
380        assert_eq!(msg.content_blocks.len(), 1);
381    }
382
383    #[test]
384    fn normalize_both_empty_is_noop() {
385        let mut msg = make_msg("", vec![]);
386        msg.normalize();
387        assert_eq!(msg.content, "");
388        assert!(msg.content_blocks.is_empty());
389    }
390
391    // ── serialize_for_storage() ─────────────────────────────────────
392
393    #[test]
394    fn serialize_for_storage_omits_content_field() {
395        let msg = make_msg(
396            "should be removed",
397            vec![ContentBlock::Text {
398                text: "canonical".into(),
399            }],
400        );
401        let json_str = msg.serialize_for_storage().unwrap();
402        let val: serde_json::Value = serde_json::from_str(&json_str).unwrap();
403        assert!(
404            val.get("content").is_none(),
405            "content field should be omitted"
406        );
407        assert!(val.get("content_blocks").is_some());
408    }
409
410    #[test]
411    fn serialize_for_storage_roundtrips_other_fields() {
412        let msg = make_msg(
413            "ignored",
414            vec![ContentBlock::Text {
415                text: "hello".into(),
416            }],
417        );
418        let json_str = msg.serialize_for_storage().unwrap();
419        let val: serde_json::Value = serde_json::from_str(&json_str).unwrap();
420        assert_eq!(val["msg_id"], "m1");
421        assert_eq!(val["role"], "user");
422        assert_eq!(val["timestamp"], "2026-01-01T00:00:00Z");
423    }
424
425    // ── derive_content_from_blocks_with ─────────────────────────────
426
427    #[test]
428    fn derive_content_excludes_tool_results_when_flag_false() {
429        let blocks = vec![
430            ContentBlock::Text {
431                text: "hello".into(),
432            },
433            ContentBlock::ToolResult {
434                tool_use_id: "t1".into(),
435                content: "result".into(),
436                is_error: false,
437            },
438        ];
439        assert_eq!(derive_content_from_blocks_with(&blocks, false), "hello");
440        assert_eq!(
441            derive_content_from_blocks_with(&blocks, true),
442            "hello\nresult"
443        );
444    }
445
446    #[test]
447    fn derive_content_mixed_text_and_tool_result() {
448        let blocks = vec![
449            ContentBlock::ToolResult {
450                tool_use_id: "t1".into(),
451                content: "tool output".into(),
452                is_error: false,
453            },
454            ContentBlock::ToolResult {
455                tool_use_id: "t2".into(),
456                content: "more output".into(),
457                is_error: false,
458            },
459        ];
460        assert_eq!(
461            derive_content_from_blocks(&blocks),
462            "tool output\nmore output"
463        );
464    }
465}