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)]
166pub enum MessageRole {
167    User,
168    Assistant,
169    System,
170    /// Tool result message (OpenAI-compatible format for function calling)
171    Tool,
172}
173
174// F74: version-skew-tolerant deserialize. A conversation written by a NEWER build
175// may carry a `role` string this build doesn't model; the derived `Deserialize`
176// would hard-fail the WHOLE `ConversationHistory` parse, so `--continue` silently
177// skipped the newest session. We instead accept any string and map an unknown
178// role to the neutral `System` so the session still loads and resumes.
179//
180// We deliberately do NOT add a dedicated `MessageRole::Unknown` variant here:
181// `MessageRole` is matched exhaustively by the provider adapters
182// (anthropic/openai_compat/ollama/gemini) and the chat renderer/compaction
183// formatter, all outside this change's allowed file set, and a new variant would
184// fail those `match` arms to compile. Mapping unknown→System keeps the wire
185// format and every existing `match` intact while making the parse tolerant
186// ("treat as a neutral role; don't panic").
187impl<'de> Deserialize<'de> for MessageRole {
188    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
189    where
190        D: serde::Deserializer<'de>,
191    {
192        let raw = String::deserialize(deserializer)?;
193        Ok(match raw.as_str() {
194            "User" => MessageRole::User,
195            "Assistant" => MessageRole::Assistant,
196            "System" => MessageRole::System,
197            "Tool" => MessageRole::Tool,
198            other => {
199                tracing::warn!(
200                    role = %other,
201                    "unknown message role in saved conversation; treating as System (version skew?)"
202                );
203                MessageRole::System
204            },
205        })
206    }
207}
208
209#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename_all = "snake_case")]
211pub enum ChatMessageKind {
212    #[default]
213    Normal,
214    ContextCheckpoint,
215    /// A display-only run summary ("Worked for … · used … tokens"). Rendered
216    /// dim/italic; excluded from the model context by `build_chat_request`.
217    RunSummary,
218    /// F74: a kind written by a NEWER build that this one doesn't model. Mapped
219    /// here by `#[serde(other)]` instead of failing the whole conversation parse;
220    /// it's neither a checkpoint nor a run summary, so every `matches!` site
221    /// treats it like a normal message. (`ChatMessageKind` is never matched
222    /// exhaustively, so adding this variant is compile-safe.)
223    #[serde(other)]
224    Unknown,
225}
226
227/// Why a model stopped generating, normalized across providers.
228///
229/// Providers report this as Anthropic `stop_reason`, OpenAI `finish_reason`,
230/// or Gemini `finishReason`. It used to be parsed and discarded, so a
231/// `max_tokens` truncation or a `content_filter`/safety block looked identical
232/// to a clean finish. The agent loop now inspects it: `Length` surfaces a
233/// truncation notice, and an empty `ContentFilter` finish becomes an error.
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(rename_all = "snake_case")]
236pub enum FinishReason {
237    /// Normal end of turn (`end_turn`, `stop`, `stop_sequence`, `STOP`).
238    Stop,
239    /// Stopped to call a tool (`tool_use`, `tool_calls`).
240    ToolUse,
241    /// Hit the output token limit — the response is truncated.
242    Length,
243    /// Blocked by a content filter / safety system / recitation check.
244    ContentFilter,
245    /// A reason we don't specifically model; carries the raw provider string.
246    Other(String),
247}
248
249/// Response from a model
250#[derive(Debug, Clone)]
251pub struct ModelResponse {
252    /// The actual response text
253    pub content: String,
254    /// Usage statistics if available
255    pub usage: Option<TokenUsage>,
256    /// Model that generated the response
257    pub model_name: String,
258    /// Thinking/reasoning content (for models that expose their thought process)
259    pub thinking: Option<String>,
260    /// Tool calls from the model (Ollama native function calling)
261    pub tool_calls: Option<Vec<crate::models::tool_call::ToolCall>>,
262    /// Why generation stopped, when the provider reported it. `None` if the
263    /// provider didn't say (or the adapter doesn't yet map it).
264    pub stop_reason: Option<FinishReason>,
265    /// Anthropic thinking-block signature (encrypted server state). Set
266    /// only by `AnthropicAdapter`; other adapters leave it `None`. The
267    /// agent loop's commit step copies this onto the resulting assistant
268    /// `ChatMessage::thinking_signature` so it round-trips on the next
269    /// turn — without this, multi-turn Claude conversations with
270    /// extended thinking 400 with `invalid_request_error`.
271    pub thinking_signature: Option<String>,
272}
273
274/// Where a token count came from. Provider-reported counts are the
275/// billing/request truth; estimates are only for preflight context
276/// diagnostics.
277#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(rename_all = "snake_case")]
279pub enum TokenUsageSource {
280    #[default]
281    Provider,
282    Estimate,
283}
284
285/// Token usage statistics normalized across providers.
286///
287/// `prompt_tokens`, `completion_tokens`, and `total_tokens` preserve
288/// the old public surface. Extra fields keep cache/reasoning detail so
289/// UI can stop flattening unlike provider concepts into one number.
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291pub struct TokenUsage {
292    pub prompt_tokens: usize,
293    pub completion_tokens: usize,
294    pub total_tokens: usize,
295    #[serde(default)]
296    pub cached_input_tokens: usize,
297    #[serde(default)]
298    pub cache_creation_input_tokens: usize,
299    #[serde(default)]
300    pub reasoning_output_tokens: usize,
301    #[serde(default)]
302    pub source: TokenUsageSource,
303}
304
305impl TokenUsage {
306    pub fn provider(prompt_tokens: usize, completion_tokens: usize, total_tokens: usize) -> Self {
307        Self {
308            prompt_tokens,
309            completion_tokens,
310            total_tokens,
311            cached_input_tokens: 0,
312            cache_creation_input_tokens: 0,
313            reasoning_output_tokens: 0,
314            source: TokenUsageSource::Provider,
315        }
316    }
317
318    pub fn estimate(prompt_tokens: usize) -> Self {
319        Self {
320            prompt_tokens,
321            completion_tokens: 0,
322            total_tokens: prompt_tokens,
323            cached_input_tokens: 0,
324            cache_creation_input_tokens: 0,
325            reasoning_output_tokens: 0,
326            source: TokenUsageSource::Estimate,
327        }
328    }
329
330    pub fn with_cached_input(mut self, cached_input_tokens: usize) -> Self {
331        self.cached_input_tokens = cached_input_tokens;
332        self
333    }
334
335    pub fn with_cache_creation(mut self, cache_creation_input_tokens: usize) -> Self {
336        self.cache_creation_input_tokens = cache_creation_input_tokens;
337        self
338    }
339
340    pub fn with_reasoning_output(mut self, reasoning_output_tokens: usize) -> Self {
341        self.reasoning_output_tokens = reasoning_output_tokens;
342        self
343    }
344
345    pub fn input_total_tokens(&self) -> usize {
346        self.prompt_tokens
347            .saturating_add(self.cached_input_tokens)
348            .saturating_add(self.cache_creation_input_tokens)
349    }
350
351    pub fn output_total_tokens(&self) -> usize {
352        self.completion_tokens
353            .saturating_add(self.reasoning_output_tokens)
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn test_message_role_equality() {
363        let user1 = MessageRole::User;
364        let user2 = MessageRole::User;
365        let assistant = MessageRole::Assistant;
366
367        assert_eq!(user1, user2, "User roles should be equal");
368        assert_ne!(user1, assistant, "Different roles should not be equal");
369    }
370
371    #[test]
372    fn test_chat_message_constructors() {
373        let user = ChatMessage::user("Hello!");
374        assert_eq!(user.role, MessageRole::User);
375        assert_eq!(user.content, "Hello!");
376        assert!(user.tool_calls.is_none());
377
378        let assistant = ChatMessage::assistant("Hi there");
379        assert_eq!(assistant.role, MessageRole::Assistant);
380
381        let system = ChatMessage::system("You are helpful");
382        assert_eq!(system.role, MessageRole::System);
383
384        let tool = ChatMessage::tool("call_1", "read_file", "file contents");
385        assert_eq!(tool.role, MessageRole::Tool);
386        assert_eq!(tool.tool_call_id, Some("call_1".to_string()));
387        assert_eq!(tool.tool_name, Some("read_file".to_string()));
388    }
389
390    #[test]
391    fn test_chat_message_builders() {
392        let msg = ChatMessage::user("test").with_images(vec!["base64data".to_string()]);
393        assert_eq!(msg.images, Some(vec!["base64data".to_string()]));
394    }
395
396    #[test]
397    fn test_token_usage_structure() {
398        let usage = TokenUsage::provider(100, 50, 150)
399            .with_cached_input(25)
400            .with_reasoning_output(10);
401
402        assert_eq!(usage.prompt_tokens, 100);
403        assert_eq!(usage.completion_tokens, 50);
404        assert_eq!(usage.total_tokens, 150);
405        assert_eq!(usage.cached_input_tokens, 25);
406        assert_eq!(usage.reasoning_output_tokens, 10);
407        assert_eq!(usage.source, TokenUsageSource::Provider);
408    }
409
410    // --- extract_thinking ---
411
412    #[test]
413    fn extract_thinking_no_marker_returns_text_unchanged() {
414        let (thinking, answer) = ChatMessage::extract_thinking("just a plain answer");
415        assert_eq!(thinking, None);
416        assert_eq!(answer, "just a plain answer");
417    }
418
419    #[test]
420    fn extract_thinking_complete_block() {
421        let raw = "Thinking...\n  reasoning here\n...done thinking.\n\nFinal answer";
422        let (thinking, answer) = ChatMessage::extract_thinking(raw);
423        assert_eq!(thinking.as_deref(), Some("reasoning here"));
424        assert_eq!(answer, "Final answer");
425    }
426
427    #[test]
428    fn thinking_signature_round_trips_through_serde() {
429        // Anthropic encrypted server state — must survive
430        // serialize/deserialize so saved conversations resume cleanly.
431        let msg = ChatMessage::assistant("Step 3 lives.")
432            .with_thinking_signature("sig_abc123_encrypted_blob");
433        let json = serde_json::to_string(&msg).expect("serialize");
434        let back: ChatMessage = serde_json::from_str(&json).expect("deserialize");
435        assert_eq!(
436            back.thinking_signature.as_deref(),
437            Some("sig_abc123_encrypted_blob")
438        );
439        assert_eq!(back.content, "Step 3 lives.");
440    }
441
442    #[test]
443    fn thinking_signature_defaults_to_none() {
444        // Backward compat: messages saved before Step 3 won't have the
445        // field. Serde default kicks in — None — and deserialize
446        // succeeds without errors.
447        let pre_step3_json = r#"{
448            "role": "Assistant",
449            "content": "hello",
450            "timestamp": "2026-04-16T12:00:00-04:00"
451        }"#;
452        let msg: ChatMessage = serde_json::from_str(pre_step3_json).expect("backward compat");
453        assert!(msg.thinking_signature.is_none());
454    }
455
456    #[test]
457    fn unknown_message_role_deserializes_to_system() {
458        // F74: a role string from a newer build must not fail the parse — it maps
459        // to the neutral System role so the conversation still loads (`--continue`
460        // no longer skips the newest session).
461        let role: MessageRole = serde_json::from_str("\"Developer\"").expect("tolerant");
462        assert_eq!(role, MessageRole::System);
463        // Known roles still map correctly.
464        assert_eq!(
465            serde_json::from_str::<MessageRole>("\"Tool\"").unwrap(),
466            MessageRole::Tool
467        );
468    }
469
470    #[test]
471    fn unknown_message_kind_deserializes_to_unknown() {
472        // F74: an unknown ChatMessageKind maps to Unknown via #[serde(other)]
473        // rather than failing the parse; it's treated like a normal message.
474        let kind: ChatMessageKind = serde_json::from_str("\"some_future_kind\"").expect("tolerant");
475        assert_eq!(kind, ChatMessageKind::Unknown);
476        assert_ne!(kind, ChatMessageKind::Normal);
477    }
478
479    #[test]
480    fn chat_message_with_unknown_role_round_trips() {
481        // The whole ChatMessage deserialize succeeds despite an unknown role.
482        let json = r#"{
483            "role": "Developer",
484            "content": "hi",
485            "timestamp": "2026-04-16T12:00:00-04:00"
486        }"#;
487        let msg: ChatMessage = serde_json::from_str(json).expect("tolerant");
488        assert_eq!(msg.role, MessageRole::System);
489        assert_eq!(msg.content, "hi");
490    }
491
492    #[test]
493    fn extract_thinking_in_progress_no_end_marker() {
494        let raw = "Thinking...\n  partial reasoning so far";
495        let (thinking, answer) = ChatMessage::extract_thinking(raw);
496        assert_eq!(thinking.as_deref(), Some("partial reasoning so far"));
497        assert_eq!(answer, "");
498    }
499
500    #[test]
501    fn test_model_response_creation() {
502        let usage = TokenUsage::provider(100, 50, 150);
503
504        let response = ModelResponse {
505            content: "Hello, world!".to_string(),
506            usage: Some(usage),
507            model_name: "ollama/tinyllama".to_string(),
508            thinking: None,
509            tool_calls: None,
510            stop_reason: None,
511            thinking_signature: None,
512        };
513
514        assert_eq!(response.content, "Hello, world!");
515        assert!(response.usage.is_some());
516        assert_eq!(response.model_name, "ollama/tinyllama");
517        assert_eq!(response.usage.unwrap().total_tokens, 150);
518        assert!(response.tool_calls.is_none());
519    }
520}