Skip to main content

mermaid_cli/models/
types.rs

1use crate::domain::ActionDisplay;
2use serde::{Deserialize, Serialize};
3
4/// Opaque provider-owned state that must be replayed with a committed assistant
5/// turn. The reducer carries this as inert data; only the matching provider
6/// interprets it.
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8#[serde(tag = "provider", rename_all = "snake_case")]
9pub enum ProviderContinuation {
10    /// Anthropic's signed extended-thinking block.
11    Anthropic { signature: String },
12    /// Meta Responses output items, including encrypted reasoning state.
13    MetaResponses { output: Vec<MetaResponseItem> },
14}
15
16impl ProviderContinuation {
17    pub fn anthropic_signature(&self) -> Option<&str> {
18        match self {
19            Self::Anthropic { signature } => Some(signature),
20            Self::MetaResponses { .. } => None,
21        }
22    }
23
24    pub fn meta_output(&self) -> Option<&[MetaResponseItem]> {
25        match self {
26            Self::MetaResponses { output } => Some(output),
27            Self::Anthropic { .. } => None,
28        }
29    }
30
31    pub fn retain_meta_function_calls(&mut self, mut keep: impl FnMut(&str) -> bool) {
32        if let Self::MetaResponses { output } = self {
33            output.retain(|item| item.function_call_id().is_none_or(&mut keep));
34        }
35    }
36}
37
38/// One Meta Responses output item saved for stateless replay. Reasoning items
39/// split their encrypted payload from the remaining JSON so the ciphertext can
40/// be serialized as base64 bytes. This keeps generic persistence redaction from
41/// mistaking a ciphertext for a credential and corrupting the replay state.
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43#[serde(tag = "kind", rename_all = "snake_case")]
44pub enum MetaResponseItem {
45    Reasoning {
46        item: serde_json::Value,
47        #[serde(with = "crate::utils::serde_base64::string")]
48        encrypted_content: String,
49    },
50    Other {
51        item: serde_json::Value,
52    },
53}
54
55impl MetaResponseItem {
56    pub fn from_wire(mut item: serde_json::Value) -> Self {
57        let is_reasoning =
58            item.get("type").and_then(serde_json::Value::as_str) == Some("reasoning");
59        if is_reasoning
60            && let Some(encrypted) = item
61                .as_object_mut()
62                .and_then(|object| object.remove("encrypted_content"))
63                .and_then(|value| value.as_str().map(str::to_string))
64        {
65            return Self::Reasoning {
66                item,
67                encrypted_content: encrypted,
68            };
69        }
70        Self::Other { item }
71    }
72
73    pub fn to_wire(&self) -> serde_json::Value {
74        match self {
75            Self::Reasoning {
76                item,
77                encrypted_content,
78            } => {
79                let mut item = item.clone();
80                if let Some(object) = item.as_object_mut() {
81                    object.insert(
82                        "encrypted_content".to_string(),
83                        serde_json::Value::String(encrypted_content.clone()),
84                    );
85                }
86                item
87            },
88            Self::Other { item } => item.clone(),
89        }
90    }
91
92    pub fn function_call_id(&self) -> Option<&str> {
93        let item = match self {
94            Self::Reasoning { item, .. } | Self::Other { item } => item,
95        };
96        (item.get("type").and_then(serde_json::Value::as_str) == Some("function_call"))
97            .then(|| item.get("call_id").and_then(serde_json::Value::as_str))
98            .flatten()
99    }
100}
101
102/// Represents a chat message
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct ChatMessage {
105    pub role: MessageRole,
106    pub content: String,
107    pub timestamp: chrono::DateTime<chrono::Local>,
108    /// Mermaid-owned message classification. Provider adapters ignore
109    /// this; render/persistence use it to distinguish generated
110    /// checkpoints from normal user/assistant turns.
111    #[serde(default)]
112    pub kind: ChatMessageKind,
113    /// Optional Mermaid-owned structured metadata for UI/replay.
114    #[serde(default)]
115    pub metadata: Option<serde_json::Value>,
116    /// Actions performed during this message (for display purposes)
117    #[serde(default)]
118    pub actions: Vec<ActionDisplay>,
119    /// Thinking/reasoning content (for models that expose their thought process)
120    #[serde(default)]
121    pub thinking: Option<String>,
122    /// Base64-encoded images/PDFs for multimodal models
123    #[serde(default)]
124    pub images: Option<Vec<String>>,
125    /// Global `[Image #N]` display numbers, parallel to `images` (same length,
126    /// same order). Kept separate from `images` so provider adapters and the
127    /// `/context` image count keep reading `images: Vec<String>` unchanged, and
128    /// so sessions saved before image numbering deserialize cleanly (`None` →
129    /// the transcript falls back to a positional index).
130    #[serde(default)]
131    pub image_numbers: Option<Vec<u64>>,
132    /// Tool calls from the model (Ollama native function calling)
133    #[serde(default)]
134    pub tool_calls: Option<Vec<crate::models::tool_call::ToolCall>>,
135    /// Tool call ID for tool result messages (OpenAI-compatible format)
136    /// This links the tool result back to the original tool_call from the assistant
137    #[serde(default)]
138    pub tool_call_id: Option<String>,
139    /// Tool name for tool result messages (required by Ollama API)
140    /// This tells the model which function's result is being returned
141    #[serde(default)]
142    pub tool_name: Option<String>,
143    /// Provider-owned continuation state. Anthropic stores its signed thinking
144    /// block; Meta stores ordered Responses output items for encrypted replay.
145    /// Other providers leave this unset and ignore it on the wire.
146    #[serde(default)]
147    pub provider_continuation: Option<ProviderContinuation>,
148}
149
150impl ChatMessage {
151    /// Create a user message
152    pub fn user(content: impl Into<String>) -> Self {
153        Self::new(MessageRole::User, content.into())
154    }
155
156    /// Create an assistant message
157    pub fn assistant(content: impl Into<String>) -> Self {
158        Self::new(MessageRole::Assistant, content.into())
159    }
160
161    /// Create a system message
162    pub fn system(content: impl Into<String>) -> Self {
163        Self::new(MessageRole::System, content.into())
164    }
165
166    /// Create a display-only run summary (e.g. "Worked for 5m 12s · used 12.3k
167    /// tokens"). Rendered dim/italic where the spinner was; excluded from the
168    /// model context by `build_chat_request` so it never bloats the conversation.
169    pub fn run_summary(content: impl Into<String>) -> Self {
170        let mut m = Self::new(MessageRole::System, content.into());
171        m.kind = ChatMessageKind::RunSummary;
172        m
173    }
174
175    /// Create a tool result message
176    pub fn tool(
177        tool_call_id: impl Into<String>,
178        tool_name: impl Into<String>,
179        content: impl Into<String>,
180    ) -> Self {
181        Self {
182            role: MessageRole::Tool,
183            content: content.into(),
184            timestamp: chrono::Local::now(),
185            kind: ChatMessageKind::Normal,
186            metadata: None,
187            actions: Vec::new(),
188            thinking: None,
189            images: None,
190            image_numbers: None,
191            tool_calls: None,
192            tool_call_id: Some(tool_call_id.into()),
193            tool_name: Some(tool_name.into()),
194            provider_continuation: None,
195        }
196    }
197
198    /// Base constructor with role and content
199    fn new(role: MessageRole, content: String) -> Self {
200        Self {
201            role,
202            content,
203            timestamp: chrono::Local::now(),
204            kind: ChatMessageKind::Normal,
205            metadata: None,
206            actions: Vec::new(),
207            thinking: None,
208            images: None,
209            image_numbers: None,
210            tool_calls: None,
211            tool_call_id: None,
212            tool_name: None,
213            provider_continuation: None,
214        }
215    }
216
217    /// Builder: attach images
218    pub fn with_images(mut self, images: Vec<String>) -> Self {
219        self.images = Some(images);
220        self
221    }
222
223    /// Builder: attach the parallel global image numbers (same length/order as
224    /// `with_images`). Set together at submit time so the transcript can show
225    /// each image's stable `[Image #N]`.
226    pub fn with_image_numbers(mut self, numbers: Vec<u64>) -> Self {
227        self.image_numbers = Some(numbers);
228        self
229    }
230
231    /// Builder: attach tool calls
232    pub fn with_tool_calls(mut self, tool_calls: Vec<crate::models::tool_call::ToolCall>) -> Self {
233        self.tool_calls = if tool_calls.is_empty() {
234            None
235        } else {
236            Some(tool_calls)
237        };
238        self
239    }
240
241    /// Builder: attach opaque provider continuation data.
242    pub fn with_provider_continuation(mut self, continuation: ProviderContinuation) -> Self {
243        self.provider_continuation = Some(continuation);
244        self
245    }
246
247    /// Extract thinking blocks from message content.
248    /// Returns `(thinking_content, answer_content)`.
249    ///
250    /// Performs a single `find` for the start marker; the previous version
251    /// scanned twice (`contains` + `find`) and called `find("Thinking...")`
252    /// again inside the if-let-chain.
253    ///
254    /// Safety: `str::find()` returns byte offsets. The markers `"Thinking..."`
255    /// and `"...done thinking."` are pure ASCII, so adding their `.len()`
256    /// always lands on a valid UTF-8 char boundary.
257    pub fn extract_thinking(text: &str) -> (Option<String>, String) {
258        let Some(thinking_start) = text.find("Thinking...") else {
259            return (None, text.to_string());
260        };
261        let content_start = thinking_start + "Thinking...".len();
262
263        if let Some(thinking_end) = text.find("...done thinking.") {
264            let thinking_text = text[content_start..thinking_end].trim().to_string();
265            let answer_start = thinking_end + "...done thinking.".len();
266            let answer_text = text[answer_start..].trim().to_string();
267            return (Some(thinking_text), answer_text);
268        }
269
270        // Start marker without end marker — thinking is still in progress.
271        let thinking_text = text[content_start..].trim().to_string();
272        (Some(thinking_text), String::new())
273    }
274}
275
276#[derive(Debug, Clone, PartialEq, Serialize)]
277pub enum MessageRole {
278    User,
279    Assistant,
280    System,
281    /// Tool result message (OpenAI-compatible format for function calling)
282    Tool,
283}
284
285// F74: version-skew-tolerant deserialize. A conversation written by a NEWER build
286// may carry a `role` string this build doesn't model; the derived `Deserialize`
287// would hard-fail the WHOLE `ConversationHistory` parse, so `--continue` silently
288// skipped the newest session. We instead accept any string and map an unknown
289// role to the neutral `System` so the session still loads and resumes.
290//
291// We deliberately do NOT add a dedicated `MessageRole::Unknown` variant here:
292// `MessageRole` is matched exhaustively by the provider adapters
293// (anthropic/openai_compat/ollama/gemini) and the chat renderer/compaction
294// formatter, all outside this change's allowed file set, and a new variant would
295// fail those `match` arms to compile. Mapping unknown→System keeps the wire
296// format and every existing `match` intact while making the parse tolerant
297// ("treat as a neutral role; don't panic").
298impl<'de> Deserialize<'de> for MessageRole {
299    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
300    where
301        D: serde::Deserializer<'de>,
302    {
303        let raw = String::deserialize(deserializer)?;
304        Ok(match raw.as_str() {
305            "User" => MessageRole::User,
306            "Assistant" => MessageRole::Assistant,
307            "System" => MessageRole::System,
308            "Tool" => MessageRole::Tool,
309            other => {
310                tracing::warn!(
311                    role = %other,
312                    "unknown message role in saved conversation; treating as System (version skew?)"
313                );
314                MessageRole::System
315            },
316        })
317    }
318}
319
320#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
321#[serde(rename_all = "snake_case")]
322pub enum ChatMessageKind {
323    #[default]
324    Normal,
325    ContextCheckpoint,
326    /// A display-only run summary ("Worked for … · used … tokens"). Rendered
327    /// dim/italic; excluded from the model context by `build_chat_request`.
328    RunSummary,
329    /// An assistant message that resumes a reply cut by the provider's
330    /// per-response output cap (auto-continue). Canonical history keeps it as
331    /// its own message — true to the wire, signature-safe — but the transcript
332    /// stitches it into the previous assistant bubble.
333    Continuation,
334    /// A system nudge injected to steer exactly one request (auto-continue
335    /// resume, stalled-turn retry, safety-mode-loosened note). Hidden from the
336    /// transcript and swept from history at the next turn-end — it must never
337    /// outlive the request it steers.
338    RecoveryNudge,
339    /// F74: a kind written by a NEWER build that this one doesn't model. Mapped
340    /// here by `#[serde(other)]` instead of failing the whole conversation parse;
341    /// it's neither a checkpoint nor a run summary, so every `matches!` site
342    /// treats it like a normal message. (`ChatMessageKind` is never matched
343    /// exhaustively, so adding this variant is compile-safe.)
344    #[serde(other)]
345    Unknown,
346}
347
348/// Why a model stopped generating, normalized across providers.
349///
350/// Providers report this as Anthropic `stop_reason`, OpenAI `finish_reason`,
351/// or Gemini `finishReason`. It used to be parsed and discarded, so a
352/// `max_tokens` truncation or a `content_filter`/safety block looked identical
353/// to a clean finish. The agent loop now inspects it: `Length` surfaces a
354/// truncation notice, and an empty `ContentFilter` finish becomes an error.
355#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356#[serde(rename_all = "snake_case")]
357pub enum FinishReason {
358    /// Normal end of turn (`end_turn`, `stop`, `stop_sequence`, `STOP`).
359    Stop,
360    /// Stopped to call a tool (`tool_use`, `tool_calls`).
361    ToolUse,
362    /// Hit the output token limit — the response is truncated.
363    Length,
364    /// Blocked by a content filter / safety system / recitation check.
365    ContentFilter,
366    /// A reason we don't specifically model; carries the raw provider string.
367    Other(String),
368}
369
370/// Response from a model
371#[derive(Debug, Clone)]
372pub struct ModelResponse {
373    /// The actual response text
374    pub content: String,
375    /// Usage statistics if available
376    pub usage: Option<TokenUsage>,
377    /// Model that generated the response
378    pub model_name: String,
379    /// Thinking/reasoning content (for models that expose their thought process)
380    pub thinking: Option<String>,
381    /// Tool calls from the model (Ollama native function calling)
382    pub tool_calls: Option<Vec<crate::models::tool_call::ToolCall>>,
383    /// Why generation stopped, when the provider reported it. `None` if the
384    /// provider didn't say (or the adapter doesn't yet map it).
385    pub stop_reason: Option<FinishReason>,
386    /// Opaque provider continuation state, when the adapter emits one.
387    pub provider_continuation: Option<ProviderContinuation>,
388}
389
390/// Where a token count came from. Provider-reported counts are the
391/// billing/request truth; estimates are only for preflight context
392/// diagnostics.
393#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
394#[serde(rename_all = "snake_case")]
395pub enum TokenUsageSource {
396    #[default]
397    Provider,
398    Estimate,
399}
400
401/// Token usage statistics normalized across providers.
402///
403/// Component fields are disjoint; totals are derived, never stored:
404/// - `prompt_tokens`: fresh (non-cached) input only. Adapters subtract
405///   cache reads for providers whose wire prompt count includes them.
406/// - `cached_input_tokens` / `cache_creation_input_tokens`: cache read
407///   and write.
408/// - `completion_tokens`: non-reasoning output only. Adapters subtract
409///   reasoning for providers whose wire completion count includes it.
410///   Anthropic reports no separate thinking count, so its thinking
411///   tokens ride inside `completion_tokens` and
412///   `reasoning_output_tokens` stays 0.
413/// - `reasoning_output_tokens`: disjoint from `completion_tokens`.
414#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
415pub struct TokenUsage {
416    pub prompt_tokens: usize,
417    pub completion_tokens: usize,
418    #[serde(default)]
419    pub cached_input_tokens: usize,
420    #[serde(default)]
421    pub cache_creation_input_tokens: usize,
422    #[serde(default)]
423    pub reasoning_output_tokens: usize,
424    #[serde(default)]
425    pub source: TokenUsageSource,
426}
427
428impl TokenUsage {
429    pub fn provider(prompt_tokens: usize, completion_tokens: usize) -> Self {
430        Self {
431            prompt_tokens,
432            completion_tokens,
433            cached_input_tokens: 0,
434            cache_creation_input_tokens: 0,
435            reasoning_output_tokens: 0,
436            source: TokenUsageSource::Provider,
437        }
438    }
439
440    pub fn estimate(prompt_tokens: usize) -> Self {
441        Self {
442            prompt_tokens,
443            completion_tokens: 0,
444            cached_input_tokens: 0,
445            cache_creation_input_tokens: 0,
446            reasoning_output_tokens: 0,
447            source: TokenUsageSource::Estimate,
448        }
449    }
450
451    pub fn with_cached_input(mut self, cached_input_tokens: usize) -> Self {
452        self.cached_input_tokens = cached_input_tokens;
453        self
454    }
455
456    pub fn with_cache_creation(mut self, cache_creation_input_tokens: usize) -> Self {
457        self.cache_creation_input_tokens = cache_creation_input_tokens;
458        self
459    }
460
461    pub fn with_reasoning_output(mut self, reasoning_output_tokens: usize) -> Self {
462        self.reasoning_output_tokens = reasoning_output_tokens;
463        self
464    }
465
466    pub fn input_total_tokens(&self) -> usize {
467        self.prompt_tokens
468            .saturating_add(self.cached_input_tokens)
469            .saturating_add(self.cache_creation_input_tokens)
470    }
471
472    pub fn output_total_tokens(&self) -> usize {
473        self.completion_tokens
474            .saturating_add(self.reasoning_output_tokens)
475    }
476
477    /// Full request total, derived. Equals every provider's wire total
478    /// (verified for Anthropic, OpenAI, Gemini, Ollama) — a stored total
479    /// would only reintroduce per-provider drift.
480    pub fn total_tokens(&self) -> usize {
481        self.input_total_tokens()
482            .saturating_add(self.output_total_tokens())
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    #[test]
491    fn test_message_role_equality() {
492        let user1 = MessageRole::User;
493        let user2 = MessageRole::User;
494        let assistant = MessageRole::Assistant;
495
496        assert_eq!(user1, user2, "User roles should be equal");
497        assert_ne!(user1, assistant, "Different roles should not be equal");
498    }
499
500    #[test]
501    fn test_chat_message_constructors() {
502        let user = ChatMessage::user("Hello!");
503        assert_eq!(user.role, MessageRole::User);
504        assert_eq!(user.content, "Hello!");
505        assert!(user.tool_calls.is_none());
506
507        let assistant = ChatMessage::assistant("Hi there");
508        assert_eq!(assistant.role, MessageRole::Assistant);
509
510        let system = ChatMessage::system("You are helpful");
511        assert_eq!(system.role, MessageRole::System);
512
513        let tool = ChatMessage::tool("call_1", "read_file", "file contents");
514        assert_eq!(tool.role, MessageRole::Tool);
515        assert_eq!(tool.tool_call_id, Some("call_1".to_string()));
516        assert_eq!(tool.tool_name, Some("read_file".to_string()));
517    }
518
519    #[test]
520    fn test_chat_message_builders() {
521        let msg = ChatMessage::user("test").with_images(vec!["base64data".to_string()]);
522        assert_eq!(msg.images, Some(vec!["base64data".to_string()]));
523    }
524
525    #[test]
526    fn test_token_usage_structure() {
527        let usage = TokenUsage::provider(100, 50)
528            .with_cached_input(25)
529            .with_cache_creation(5)
530            .with_reasoning_output(10);
531
532        assert_eq!(usage.prompt_tokens, 100);
533        assert_eq!(usage.completion_tokens, 50);
534        assert_eq!(usage.cached_input_tokens, 25);
535        assert_eq!(usage.cache_creation_input_tokens, 5);
536        assert_eq!(usage.reasoning_output_tokens, 10);
537        assert_eq!(usage.input_total_tokens(), 130);
538        assert_eq!(usage.output_total_tokens(), 60);
539        assert_eq!(usage.total_tokens(), 190);
540        assert_eq!(usage.source, TokenUsageSource::Provider);
541    }
542
543    // --- extract_thinking ---
544
545    #[test]
546    fn extract_thinking_no_marker_returns_text_unchanged() {
547        let (thinking, answer) = ChatMessage::extract_thinking("just a plain answer");
548        assert_eq!(thinking, None);
549        assert_eq!(answer, "just a plain answer");
550    }
551
552    #[test]
553    fn extract_thinking_complete_block() {
554        let raw = "Thinking...\n  reasoning here\n...done thinking.\n\nFinal answer";
555        let (thinking, answer) = ChatMessage::extract_thinking(raw);
556        assert_eq!(thinking.as_deref(), Some("reasoning here"));
557        assert_eq!(answer, "Final answer");
558    }
559
560    #[test]
561    fn provider_continuation_round_trips_through_serde() {
562        // Anthropic encrypted server state — must survive
563        // serialize/deserialize so saved conversations resume cleanly.
564        let msg = ChatMessage::assistant("Step 3 lives.").with_provider_continuation(
565            ProviderContinuation::Anthropic {
566                signature: "sig_abc123_encrypted_blob".to_string(),
567            },
568        );
569        let json = serde_json::to_string(&msg).expect("serialize");
570        let back: ChatMessage = serde_json::from_str(&json).expect("deserialize");
571        assert_eq!(
572            back.provider_continuation
573                .as_ref()
574                .and_then(ProviderContinuation::anthropic_signature),
575            Some("sig_abc123_encrypted_blob")
576        );
577        assert_eq!(back.content, "Step 3 lives.");
578    }
579
580    #[test]
581    fn provider_continuation_defaults_to_none() {
582        // Backward compat: messages saved before Step 3 won't have the
583        // field. Serde default kicks in — None — and deserialize
584        // succeeds without errors.
585        let pre_step3_json = r#"{
586            "role": "Assistant",
587            "content": "hello",
588            "timestamp": "2026-04-16T12:00:00-04:00"
589        }"#;
590        let msg: ChatMessage = serde_json::from_str(pre_step3_json).expect("backward compat");
591        assert!(msg.provider_continuation.is_none());
592    }
593
594    #[test]
595    fn meta_encrypted_continuation_survives_persistence_redaction_byte_exact() {
596        let original = "eyJopaque.reasoning.payload";
597        let message = ChatMessage::assistant("done").with_provider_continuation(
598            ProviderContinuation::MetaResponses {
599                output: vec![MetaResponseItem::from_wire(serde_json::json!({
600                    "type": "reasoning",
601                    "id": "rs_1",
602                    "summary": [],
603                    "encrypted_content": original,
604                }))],
605            },
606        );
607        let mut persisted = serde_json::to_value(message).unwrap();
608        crate::utils::redact_json(&mut persisted);
609        let restored: ChatMessage = serde_json::from_value(persisted).unwrap();
610        let output = restored
611            .provider_continuation
612            .as_ref()
613            .and_then(ProviderContinuation::meta_output)
614            .unwrap();
615        assert_eq!(output[0].to_wire()["encrypted_content"], original);
616    }
617
618    #[test]
619    fn unknown_message_role_deserializes_to_system() {
620        // F74: a role string from a newer build must not fail the parse — it maps
621        // to the neutral System role so the conversation still loads (`--continue`
622        // no longer skips the newest session).
623        let role: MessageRole = serde_json::from_str("\"Developer\"").expect("tolerant");
624        assert_eq!(role, MessageRole::System);
625        // Known roles still map correctly.
626        assert_eq!(
627            serde_json::from_str::<MessageRole>("\"Tool\"").unwrap(),
628            MessageRole::Tool
629        );
630    }
631
632    #[test]
633    fn unknown_message_kind_deserializes_to_unknown() {
634        // F74: an unknown ChatMessageKind maps to Unknown via #[serde(other)]
635        // rather than failing the parse; it's treated like a normal message.
636        let kind: ChatMessageKind = serde_json::from_str("\"some_future_kind\"").expect("tolerant");
637        assert_eq!(kind, ChatMessageKind::Unknown);
638        assert_ne!(kind, ChatMessageKind::Normal);
639    }
640
641    #[test]
642    fn continuation_kinds_round_trip_through_serde() {
643        // The stitch markers must survive session save/load: a reloaded
644        // transcript stitches exactly like the live one did.
645        for kind in [
646            ChatMessageKind::Continuation,
647            ChatMessageKind::RecoveryNudge,
648        ] {
649            let json = serde_json::to_string(&kind).unwrap();
650            let back: ChatMessageKind = serde_json::from_str(&json).unwrap();
651            assert_eq!(back, kind);
652        }
653        // And the snake_case wire form is stable.
654        assert_eq!(
655            serde_json::to_string(&ChatMessageKind::Continuation).unwrap(),
656            "\"continuation\""
657        );
658        assert_eq!(
659            serde_json::to_string(&ChatMessageKind::RecoveryNudge).unwrap(),
660            "\"recovery_nudge\""
661        );
662    }
663
664    #[test]
665    fn chat_message_with_unknown_role_round_trips() {
666        // The whole ChatMessage deserialize succeeds despite an unknown role.
667        let json = r#"{
668            "role": "Developer",
669            "content": "hi",
670            "timestamp": "2026-04-16T12:00:00-04:00"
671        }"#;
672        let msg: ChatMessage = serde_json::from_str(json).expect("tolerant");
673        assert_eq!(msg.role, MessageRole::System);
674        assert_eq!(msg.content, "hi");
675    }
676
677    #[test]
678    fn extract_thinking_in_progress_no_end_marker() {
679        let raw = "Thinking...\n  partial reasoning so far";
680        let (thinking, answer) = ChatMessage::extract_thinking(raw);
681        assert_eq!(thinking.as_deref(), Some("partial reasoning so far"));
682        assert_eq!(answer, "");
683    }
684
685    #[test]
686    fn test_model_response_creation() {
687        let usage = TokenUsage::provider(100, 50);
688
689        let response = ModelResponse {
690            content: "Hello, world!".to_string(),
691            usage: Some(usage),
692            model_name: "ollama/tinyllama".to_string(),
693            thinking: None,
694            tool_calls: None,
695            stop_reason: None,
696            provider_continuation: None,
697        };
698
699        assert_eq!(response.content, "Hello, world!");
700        assert!(response.usage.is_some());
701        assert_eq!(response.model_name, "ollama/tinyllama");
702        assert_eq!(response.usage.unwrap().total_tokens(), 150);
703        assert!(response.tool_calls.is_none());
704    }
705}