Skip to main content

mermaid_cli/models/
types.rs

1use crate::domain::ActionDisplay;
2use serde::{Deserialize, Serialize};
3
4/// Represents a chat message
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct ChatMessage {
7    pub role: MessageRole,
8    pub content: String,
9    pub timestamp: chrono::DateTime<chrono::Local>,
10    /// Mermaid-owned message classification. Provider adapters ignore
11    /// this; render/persistence use it to distinguish generated
12    /// checkpoints from normal user/assistant turns.
13    #[serde(default)]
14    pub kind: ChatMessageKind,
15    /// Optional Mermaid-owned structured metadata for UI/replay.
16    #[serde(default)]
17    pub metadata: Option<serde_json::Value>,
18    /// Actions performed during this message (for display purposes)
19    #[serde(default)]
20    pub actions: Vec<ActionDisplay>,
21    /// Thinking/reasoning content (for models that expose their thought process)
22    #[serde(default)]
23    pub thinking: Option<String>,
24    /// Base64-encoded images/PDFs for multimodal models
25    #[serde(default)]
26    pub images: Option<Vec<String>>,
27    /// Tool calls from the model (Ollama native function calling)
28    #[serde(default)]
29    pub tool_calls: Option<Vec<crate::models::tool_call::ToolCall>>,
30    /// Tool call ID for tool result messages (OpenAI-compatible format)
31    /// This links the tool result back to the original tool_call from the assistant
32    #[serde(default)]
33    pub tool_call_id: Option<String>,
34    /// Tool name for tool result messages (required by Ollama API)
35    /// This tells the model which function's result is being returned
36    #[serde(default)]
37    pub tool_name: Option<String>,
38    /// Anthropic thinking-block signature — encrypted server state that
39    /// MUST round-trip back into the next request when extended thinking
40    /// is enabled, or the API returns 400 `invalid_request_error`. Set
41    /// only by the Anthropic adapter; other adapters leave it `None`
42    /// and other providers ignore it on the wire.
43    #[serde(default)]
44    pub thinking_signature: Option<String>,
45}
46
47impl ChatMessage {
48    /// Create a user message
49    pub fn user(content: impl Into<String>) -> Self {
50        Self::new(MessageRole::User, content.into())
51    }
52
53    /// Create an assistant message
54    pub fn assistant(content: impl Into<String>) -> Self {
55        Self::new(MessageRole::Assistant, content.into())
56    }
57
58    /// Create a system message
59    pub fn system(content: impl Into<String>) -> Self {
60        Self::new(MessageRole::System, content.into())
61    }
62
63    /// Create a display-only run summary (e.g. "Worked for 5m 12s · used 12.3k
64    /// tokens"). Rendered dim/italic where the spinner was; excluded from the
65    /// model context by `build_chat_request` so it never bloats the conversation.
66    pub fn run_summary(content: impl Into<String>) -> Self {
67        let mut m = Self::new(MessageRole::System, content.into());
68        m.kind = ChatMessageKind::RunSummary;
69        m
70    }
71
72    /// Create a tool result message
73    pub fn tool(
74        tool_call_id: impl Into<String>,
75        tool_name: impl Into<String>,
76        content: impl Into<String>,
77    ) -> Self {
78        Self {
79            role: MessageRole::Tool,
80            content: content.into(),
81            timestamp: chrono::Local::now(),
82            kind: ChatMessageKind::Normal,
83            metadata: None,
84            actions: Vec::new(),
85            thinking: None,
86            images: None,
87            tool_calls: None,
88            tool_call_id: Some(tool_call_id.into()),
89            tool_name: Some(tool_name.into()),
90            thinking_signature: None,
91        }
92    }
93
94    /// Base constructor with role and content
95    fn new(role: MessageRole, content: String) -> Self {
96        Self {
97            role,
98            content,
99            timestamp: chrono::Local::now(),
100            kind: ChatMessageKind::Normal,
101            metadata: None,
102            actions: Vec::new(),
103            thinking: None,
104            images: None,
105            tool_calls: None,
106            tool_call_id: None,
107            tool_name: None,
108            thinking_signature: None,
109        }
110    }
111
112    /// Builder: attach images
113    pub fn with_images(mut self, images: Vec<String>) -> Self {
114        self.images = Some(images);
115        self
116    }
117
118    /// Builder: attach tool calls
119    pub fn with_tool_calls(mut self, tool_calls: Vec<crate::models::tool_call::ToolCall>) -> Self {
120        self.tool_calls = if tool_calls.is_empty() {
121            None
122        } else {
123            Some(tool_calls)
124        };
125        self
126    }
127
128    /// Builder: attach an Anthropic thinking signature. Used by the
129    /// Anthropic adapter when committing assistant messages so the
130    /// signature can round-trip on the next request.
131    pub fn with_thinking_signature(mut self, signature: impl Into<String>) -> Self {
132        self.thinking_signature = Some(signature.into());
133        self
134    }
135
136    /// Extract thinking blocks from message content.
137    /// Returns `(thinking_content, answer_content)`.
138    ///
139    /// Performs a single `find` for the start marker; the previous version
140    /// scanned twice (`contains` + `find`) and called `find("Thinking...")`
141    /// again inside the if-let-chain.
142    ///
143    /// Safety: `str::find()` returns byte offsets. The markers `"Thinking..."`
144    /// and `"...done thinking."` are pure ASCII, so adding their `.len()`
145    /// always lands on a valid UTF-8 char boundary.
146    pub fn extract_thinking(text: &str) -> (Option<String>, String) {
147        let Some(thinking_start) = text.find("Thinking...") else {
148            return (None, text.to_string());
149        };
150        let content_start = thinking_start + "Thinking...".len();
151
152        if let Some(thinking_end) = text.find("...done thinking.") {
153            let thinking_text = text[content_start..thinking_end].trim().to_string();
154            let answer_start = thinking_end + "...done thinking.".len();
155            let answer_text = text[answer_start..].trim().to_string();
156            return (Some(thinking_text), answer_text);
157        }
158
159        // Start marker without end marker — thinking is still in progress.
160        let thinking_text = text[content_start..].trim().to_string();
161        (Some(thinking_text), String::new())
162    }
163}
164
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166pub enum MessageRole {
167    User,
168    Assistant,
169    System,
170    /// Tool result message (OpenAI-compatible format for function calling)
171    Tool,
172}
173
174#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "snake_case")]
176pub enum ChatMessageKind {
177    #[default]
178    Normal,
179    ContextCheckpoint,
180    /// A display-only run summary ("Worked for … · used … tokens"). Rendered
181    /// dim/italic; excluded from the model context by `build_chat_request`.
182    RunSummary,
183}
184
185/// Why a model stopped generating, normalized across providers.
186///
187/// Providers report this as Anthropic `stop_reason`, OpenAI `finish_reason`,
188/// or Gemini `finishReason`. It used to be parsed and discarded, so a
189/// `max_tokens` truncation or a `content_filter`/safety block looked identical
190/// to a clean finish. The agent loop now inspects it: `Length` surfaces a
191/// truncation notice, and an empty `ContentFilter` finish becomes an error.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "snake_case")]
194pub enum FinishReason {
195    /// Normal end of turn (`end_turn`, `stop`, `stop_sequence`, `STOP`).
196    Stop,
197    /// Stopped to call a tool (`tool_use`, `tool_calls`).
198    ToolUse,
199    /// Hit the output token limit — the response is truncated.
200    Length,
201    /// Blocked by a content filter / safety system / recitation check.
202    ContentFilter,
203    /// A reason we don't specifically model; carries the raw provider string.
204    Other(String),
205}
206
207/// Response from a model
208#[derive(Debug, Clone)]
209pub struct ModelResponse {
210    /// The actual response text
211    pub content: String,
212    /// Usage statistics if available
213    pub usage: Option<TokenUsage>,
214    /// Model that generated the response
215    pub model_name: String,
216    /// Thinking/reasoning content (for models that expose their thought process)
217    pub thinking: Option<String>,
218    /// Tool calls from the model (Ollama native function calling)
219    pub tool_calls: Option<Vec<crate::models::tool_call::ToolCall>>,
220    /// Why generation stopped, when the provider reported it. `None` if the
221    /// provider didn't say (or the adapter doesn't yet map it).
222    pub stop_reason: Option<FinishReason>,
223    /// Anthropic thinking-block signature (encrypted server state). Set
224    /// only by `AnthropicAdapter`; other adapters leave it `None`. The
225    /// agent loop's commit step copies this onto the resulting assistant
226    /// `ChatMessage::thinking_signature` so it round-trips on the next
227    /// turn — without this, multi-turn Claude conversations with
228    /// extended thinking 400 with `invalid_request_error`.
229    pub thinking_signature: Option<String>,
230}
231
232/// Where a token count came from. Provider-reported counts are the
233/// billing/request truth; estimates are only for preflight context
234/// diagnostics.
235#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(rename_all = "snake_case")]
237pub enum TokenUsageSource {
238    #[default]
239    Provider,
240    Estimate,
241}
242
243/// Token usage statistics normalized across providers.
244///
245/// `prompt_tokens`, `completion_tokens`, and `total_tokens` preserve
246/// the old public surface. Extra fields keep cache/reasoning detail so
247/// UI can stop flattening unlike provider concepts into one number.
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249pub struct TokenUsage {
250    pub prompt_tokens: usize,
251    pub completion_tokens: usize,
252    pub total_tokens: usize,
253    #[serde(default)]
254    pub cached_input_tokens: usize,
255    #[serde(default)]
256    pub cache_creation_input_tokens: usize,
257    #[serde(default)]
258    pub reasoning_output_tokens: usize,
259    #[serde(default)]
260    pub source: TokenUsageSource,
261}
262
263impl TokenUsage {
264    pub fn provider(prompt_tokens: usize, completion_tokens: usize, total_tokens: usize) -> Self {
265        Self {
266            prompt_tokens,
267            completion_tokens,
268            total_tokens,
269            cached_input_tokens: 0,
270            cache_creation_input_tokens: 0,
271            reasoning_output_tokens: 0,
272            source: TokenUsageSource::Provider,
273        }
274    }
275
276    pub fn estimate(prompt_tokens: usize) -> Self {
277        Self {
278            prompt_tokens,
279            completion_tokens: 0,
280            total_tokens: prompt_tokens,
281            cached_input_tokens: 0,
282            cache_creation_input_tokens: 0,
283            reasoning_output_tokens: 0,
284            source: TokenUsageSource::Estimate,
285        }
286    }
287
288    pub fn with_cached_input(mut self, cached_input_tokens: usize) -> Self {
289        self.cached_input_tokens = cached_input_tokens;
290        self
291    }
292
293    pub fn with_cache_creation(mut self, cache_creation_input_tokens: usize) -> Self {
294        self.cache_creation_input_tokens = cache_creation_input_tokens;
295        self
296    }
297
298    pub fn with_reasoning_output(mut self, reasoning_output_tokens: usize) -> Self {
299        self.reasoning_output_tokens = reasoning_output_tokens;
300        self
301    }
302
303    pub fn input_total_tokens(&self) -> usize {
304        self.prompt_tokens
305            .saturating_add(self.cached_input_tokens)
306            .saturating_add(self.cache_creation_input_tokens)
307    }
308
309    pub fn output_total_tokens(&self) -> usize {
310        self.completion_tokens
311            .saturating_add(self.reasoning_output_tokens)
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    fn test_message_role_equality() {
321        let user1 = MessageRole::User;
322        let user2 = MessageRole::User;
323        let assistant = MessageRole::Assistant;
324
325        assert_eq!(user1, user2, "User roles should be equal");
326        assert_ne!(user1, assistant, "Different roles should not be equal");
327    }
328
329    #[test]
330    fn test_chat_message_constructors() {
331        let user = ChatMessage::user("Hello!");
332        assert_eq!(user.role, MessageRole::User);
333        assert_eq!(user.content, "Hello!");
334        assert!(user.tool_calls.is_none());
335
336        let assistant = ChatMessage::assistant("Hi there");
337        assert_eq!(assistant.role, MessageRole::Assistant);
338
339        let system = ChatMessage::system("You are helpful");
340        assert_eq!(system.role, MessageRole::System);
341
342        let tool = ChatMessage::tool("call_1", "read_file", "file contents");
343        assert_eq!(tool.role, MessageRole::Tool);
344        assert_eq!(tool.tool_call_id, Some("call_1".to_string()));
345        assert_eq!(tool.tool_name, Some("read_file".to_string()));
346    }
347
348    #[test]
349    fn test_chat_message_builders() {
350        let msg = ChatMessage::user("test").with_images(vec!["base64data".to_string()]);
351        assert_eq!(msg.images, Some(vec!["base64data".to_string()]));
352    }
353
354    #[test]
355    fn test_token_usage_structure() {
356        let usage = TokenUsage::provider(100, 50, 150)
357            .with_cached_input(25)
358            .with_reasoning_output(10);
359
360        assert_eq!(usage.prompt_tokens, 100);
361        assert_eq!(usage.completion_tokens, 50);
362        assert_eq!(usage.total_tokens, 150);
363        assert_eq!(usage.cached_input_tokens, 25);
364        assert_eq!(usage.reasoning_output_tokens, 10);
365        assert_eq!(usage.source, TokenUsageSource::Provider);
366    }
367
368    // --- extract_thinking ---
369
370    #[test]
371    fn extract_thinking_no_marker_returns_text_unchanged() {
372        let (thinking, answer) = ChatMessage::extract_thinking("just a plain answer");
373        assert_eq!(thinking, None);
374        assert_eq!(answer, "just a plain answer");
375    }
376
377    #[test]
378    fn extract_thinking_complete_block() {
379        let raw = "Thinking...\n  reasoning here\n...done thinking.\n\nFinal answer";
380        let (thinking, answer) = ChatMessage::extract_thinking(raw);
381        assert_eq!(thinking.as_deref(), Some("reasoning here"));
382        assert_eq!(answer, "Final answer");
383    }
384
385    #[test]
386    fn thinking_signature_round_trips_through_serde() {
387        // Anthropic encrypted server state — must survive
388        // serialize/deserialize so saved conversations resume cleanly.
389        let msg = ChatMessage::assistant("Step 3 lives.")
390            .with_thinking_signature("sig_abc123_encrypted_blob");
391        let json = serde_json::to_string(&msg).expect("serialize");
392        let back: ChatMessage = serde_json::from_str(&json).expect("deserialize");
393        assert_eq!(
394            back.thinking_signature.as_deref(),
395            Some("sig_abc123_encrypted_blob")
396        );
397        assert_eq!(back.content, "Step 3 lives.");
398    }
399
400    #[test]
401    fn thinking_signature_defaults_to_none() {
402        // Backward compat: messages saved before Step 3 won't have the
403        // field. Serde default kicks in — None — and deserialize
404        // succeeds without errors.
405        let pre_step3_json = r#"{
406            "role": "Assistant",
407            "content": "hello",
408            "timestamp": "2026-04-16T12:00:00-04:00"
409        }"#;
410        let msg: ChatMessage = serde_json::from_str(pre_step3_json).expect("backward compat");
411        assert!(msg.thinking_signature.is_none());
412    }
413
414    #[test]
415    fn extract_thinking_in_progress_no_end_marker() {
416        let raw = "Thinking...\n  partial reasoning so far";
417        let (thinking, answer) = ChatMessage::extract_thinking(raw);
418        assert_eq!(thinking.as_deref(), Some("partial reasoning so far"));
419        assert_eq!(answer, "");
420    }
421
422    #[test]
423    fn test_model_response_creation() {
424        let usage = TokenUsage::provider(100, 50, 150);
425
426        let response = ModelResponse {
427            content: "Hello, world!".to_string(),
428            usage: Some(usage),
429            model_name: "ollama/tinyllama".to_string(),
430            thinking: None,
431            tool_calls: None,
432            stop_reason: None,
433            thinking_signature: None,
434        };
435
436        assert_eq!(response.content, "Hello, world!");
437        assert!(response.usage.is_some());
438        assert_eq!(response.model_name, "ollama/tinyllama");
439        assert_eq!(response.usage.unwrap().total_tokens, 150);
440        assert!(response.tool_calls.is_none());
441    }
442}