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