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    /// A persistent mode-change marker ("Plan mode is now ON …") injected by
340    /// the dispatch-time context-delta injector. Sent to the model on every
341    /// request — the durable timeline record that keeps history consistent
342    /// with the mode — hidden from the transcript (the status band is the
343    /// human announcement), and unlike `RecoveryNudge` NEVER swept.
344    ContextMarker,
345    /// F74: a kind written by a NEWER build that this one doesn't model. Mapped
346    /// here by `#[serde(other)]` instead of failing the whole conversation parse;
347    /// it's neither a checkpoint nor a run summary, so every `matches!` site
348    /// treats it like a normal message. (`ChatMessageKind` is never matched
349    /// exhaustively, so adding this variant is compile-safe.)
350    #[serde(other)]
351    Unknown,
352}
353
354/// Who a `MessageRole::System` history message is FOR.
355///
356/// The distinction used to be implicit, and two adapters guessed wrong: every
357/// provider with an OpenAI-shaped API passed system-role history through
358/// inline, while Anthropic and Gemini — whose APIs have exactly one top-level
359/// system field — dropped it as "a TUI affordance, not model input". That
360/// silently deleted every harness steering message on those two providers: the
361/// plan-mode reminder and context markers, but also the pre-existing
362/// auto-continue resume and stalled-turn nudges.
363///
364/// Making it explicit means an adapter can no longer guess, and
365/// [`ChatMessageKind::audience`] is the single place the decision lives.
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367pub enum MessageAudience {
368    /// Ordinary conversation content. Adapters keep their existing handling.
369    Conversation,
370    /// Harness steering the model MUST see even though the transcript hides
371    /// it. An adapter that cannot express a mid-conversation system message
372    /// has to deliver it some other way — never drop it.
373    ModelDirected,
374}
375
376impl ChatMessageKind {
377    /// The audience for a system-role message of this kind.
378    ///
379    /// Deliberately an exhaustive match with no wildcard: adding a
380    /// `ChatMessageKind` fails to compile here until its audience is decided,
381    /// which is the guard against another kind being silently dropped on the
382    /// providers that can't carry system-role history.
383    pub fn audience(self) -> MessageAudience {
384        match self {
385            // Injected to steer the model — the whole point is that it reads
386            // them. Hidden from the transcript, never from the model.
387            ChatMessageKind::RecoveryNudge | ChatMessageKind::ContextMarker => {
388                MessageAudience::ModelDirected
389            },
390            ChatMessageKind::Normal
391            | ChatMessageKind::ContextCheckpoint
392            | ChatMessageKind::RunSummary
393            | ChatMessageKind::Continuation
394            | ChatMessageKind::Unknown => MessageAudience::Conversation,
395        }
396    }
397}
398
399/// Why a model stopped generating, normalized across providers.
400///
401/// Providers report this as Anthropic `stop_reason`, OpenAI `finish_reason`,
402/// or Gemini `finishReason`. It used to be parsed and discarded, so a
403/// `max_tokens` truncation or a `content_filter`/safety block looked identical
404/// to a clean finish. The agent loop now inspects it: `Length` surfaces a
405/// truncation notice, and an empty `ContentFilter` finish becomes an error.
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
407#[serde(rename_all = "snake_case")]
408pub enum FinishReason {
409    /// Normal end of turn (`end_turn`, `stop`, `stop_sequence`, `STOP`).
410    Stop,
411    /// Stopped to call a tool (`tool_use`, `tool_calls`).
412    ToolUse,
413    /// Hit the output token limit — the response is truncated.
414    Length,
415    /// Blocked by a content filter / safety system / recitation check.
416    ContentFilter,
417    /// A reason we don't specifically model; carries the raw provider string.
418    Other(String),
419}
420
421/// Response from a model
422#[derive(Debug, Clone)]
423pub struct ModelResponse {
424    /// The actual response text
425    pub content: String,
426    /// Usage statistics if available
427    pub usage: Option<TokenUsage>,
428    /// Model that generated the response
429    pub model_name: String,
430    /// Thinking/reasoning content (for models that expose their thought process)
431    pub thinking: Option<String>,
432    /// Tool calls from the model (Ollama native function calling)
433    pub tool_calls: Option<Vec<crate::models::tool_call::ToolCall>>,
434    /// Why generation stopped, when the provider reported it. `None` if the
435    /// provider didn't say (or the adapter doesn't yet map it).
436    pub stop_reason: Option<FinishReason>,
437    /// Opaque provider continuation state, when the adapter emits one.
438    pub provider_continuation: Option<ProviderContinuation>,
439}
440
441/// Where a token count came from. Provider-reported counts are the
442/// billing/request truth; estimates are only for preflight context
443/// diagnostics.
444#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
445#[serde(rename_all = "snake_case")]
446pub enum TokenUsageSource {
447    #[default]
448    Provider,
449    Estimate,
450}
451
452/// Token usage statistics normalized across providers.
453///
454/// Component fields are disjoint; totals are derived, never stored:
455/// - `prompt_tokens`: fresh (non-cached) input only. Adapters subtract
456///   cache reads for providers whose wire prompt count includes them.
457/// - `cached_input_tokens` / `cache_creation_input_tokens`: cache read
458///   and write.
459/// - `completion_tokens`: non-reasoning output only. Adapters subtract
460///   reasoning for providers whose wire completion count includes it.
461///   Anthropic reports no separate thinking count, so its thinking
462///   tokens ride inside `completion_tokens` and
463///   `reasoning_output_tokens` stays 0.
464/// - `reasoning_output_tokens`: disjoint from `completion_tokens`.
465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
466pub struct TokenUsage {
467    pub prompt_tokens: usize,
468    pub completion_tokens: usize,
469    #[serde(default)]
470    pub cached_input_tokens: usize,
471    #[serde(default)]
472    pub cache_creation_input_tokens: usize,
473    #[serde(default)]
474    pub reasoning_output_tokens: usize,
475    #[serde(default)]
476    pub source: TokenUsageSource,
477}
478
479impl TokenUsage {
480    pub fn provider(prompt_tokens: usize, completion_tokens: usize) -> Self {
481        Self {
482            prompt_tokens,
483            completion_tokens,
484            cached_input_tokens: 0,
485            cache_creation_input_tokens: 0,
486            reasoning_output_tokens: 0,
487            source: TokenUsageSource::Provider,
488        }
489    }
490
491    pub fn estimate(prompt_tokens: usize) -> Self {
492        Self {
493            prompt_tokens,
494            completion_tokens: 0,
495            cached_input_tokens: 0,
496            cache_creation_input_tokens: 0,
497            reasoning_output_tokens: 0,
498            source: TokenUsageSource::Estimate,
499        }
500    }
501
502    pub fn with_cached_input(mut self, cached_input_tokens: usize) -> Self {
503        self.cached_input_tokens = cached_input_tokens;
504        self
505    }
506
507    pub fn with_cache_creation(mut self, cache_creation_input_tokens: usize) -> Self {
508        self.cache_creation_input_tokens = cache_creation_input_tokens;
509        self
510    }
511
512    pub fn with_reasoning_output(mut self, reasoning_output_tokens: usize) -> Self {
513        self.reasoning_output_tokens = reasoning_output_tokens;
514        self
515    }
516
517    pub fn input_total_tokens(&self) -> usize {
518        self.prompt_tokens
519            .saturating_add(self.cached_input_tokens)
520            .saturating_add(self.cache_creation_input_tokens)
521    }
522
523    pub fn output_total_tokens(&self) -> usize {
524        self.completion_tokens
525            .saturating_add(self.reasoning_output_tokens)
526    }
527
528    /// Full request total, derived. Equals every provider's wire total
529    /// (verified for Anthropic, OpenAI, Gemini, Ollama) — a stored total
530    /// would only reintroduce per-provider drift.
531    pub fn total_tokens(&self) -> usize {
532        self.input_total_tokens()
533            .saturating_add(self.output_total_tokens())
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    #[test]
542    fn test_message_role_equality() {
543        let user1 = MessageRole::User;
544        let user2 = MessageRole::User;
545        let assistant = MessageRole::Assistant;
546
547        assert_eq!(user1, user2, "User roles should be equal");
548        assert_ne!(user1, assistant, "Different roles should not be equal");
549    }
550
551    #[test]
552    fn test_chat_message_constructors() {
553        let user = ChatMessage::user("Hello!");
554        assert_eq!(user.role, MessageRole::User);
555        assert_eq!(user.content, "Hello!");
556        assert!(user.tool_calls.is_none());
557
558        let assistant = ChatMessage::assistant("Hi there");
559        assert_eq!(assistant.role, MessageRole::Assistant);
560
561        let system = ChatMessage::system("You are helpful");
562        assert_eq!(system.role, MessageRole::System);
563
564        let tool = ChatMessage::tool("call_1", "read_file", "file contents");
565        assert_eq!(tool.role, MessageRole::Tool);
566        assert_eq!(tool.tool_call_id, Some("call_1".to_string()));
567        assert_eq!(tool.tool_name, Some("read_file".to_string()));
568    }
569
570    #[test]
571    fn test_chat_message_builders() {
572        let msg = ChatMessage::user("test").with_images(vec!["base64data".to_string()]);
573        assert_eq!(msg.images, Some(vec!["base64data".to_string()]));
574    }
575
576    #[test]
577    fn test_token_usage_structure() {
578        let usage = TokenUsage::provider(100, 50)
579            .with_cached_input(25)
580            .with_cache_creation(5)
581            .with_reasoning_output(10);
582
583        assert_eq!(usage.prompt_tokens, 100);
584        assert_eq!(usage.completion_tokens, 50);
585        assert_eq!(usage.cached_input_tokens, 25);
586        assert_eq!(usage.cache_creation_input_tokens, 5);
587        assert_eq!(usage.reasoning_output_tokens, 10);
588        assert_eq!(usage.input_total_tokens(), 130);
589        assert_eq!(usage.output_total_tokens(), 60);
590        assert_eq!(usage.total_tokens(), 190);
591        assert_eq!(usage.source, TokenUsageSource::Provider);
592    }
593
594    // --- extract_thinking ---
595
596    #[test]
597    fn extract_thinking_no_marker_returns_text_unchanged() {
598        let (thinking, answer) = ChatMessage::extract_thinking("just a plain answer");
599        assert_eq!(thinking, None);
600        assert_eq!(answer, "just a plain answer");
601    }
602
603    #[test]
604    fn extract_thinking_complete_block() {
605        let raw = "Thinking...\n  reasoning here\n...done thinking.\n\nFinal answer";
606        let (thinking, answer) = ChatMessage::extract_thinking(raw);
607        assert_eq!(thinking.as_deref(), Some("reasoning here"));
608        assert_eq!(answer, "Final answer");
609    }
610
611    #[test]
612    fn provider_continuation_round_trips_through_serde() {
613        // Anthropic encrypted server state — must survive
614        // serialize/deserialize so saved conversations resume cleanly.
615        let msg = ChatMessage::assistant("Step 3 lives.").with_provider_continuation(
616            ProviderContinuation::Anthropic {
617                signature: "sig_abc123_encrypted_blob".to_string(),
618            },
619        );
620        let json = serde_json::to_string(&msg).expect("serialize");
621        let back: ChatMessage = serde_json::from_str(&json).expect("deserialize");
622        assert_eq!(
623            back.provider_continuation
624                .as_ref()
625                .and_then(ProviderContinuation::anthropic_signature),
626            Some("sig_abc123_encrypted_blob")
627        );
628        assert_eq!(back.content, "Step 3 lives.");
629    }
630
631    #[test]
632    fn provider_continuation_defaults_to_none() {
633        // Backward compat: messages saved before Step 3 won't have the
634        // field. Serde default kicks in — None — and deserialize
635        // succeeds without errors.
636        let pre_step3_json = r#"{
637            "role": "Assistant",
638            "content": "hello",
639            "timestamp": "2026-04-16T12:00:00-04:00"
640        }"#;
641        let msg: ChatMessage = serde_json::from_str(pre_step3_json).expect("backward compat");
642        assert!(msg.provider_continuation.is_none());
643    }
644
645    #[test]
646    fn meta_encrypted_continuation_survives_persistence_redaction_byte_exact() {
647        let original = "eyJopaque.reasoning.payload";
648        let message = ChatMessage::assistant("done").with_provider_continuation(
649            ProviderContinuation::MetaResponses {
650                output: vec![MetaResponseItem::from_wire(serde_json::json!({
651                    "type": "reasoning",
652                    "id": "rs_1",
653                    "summary": [],
654                    "encrypted_content": original,
655                }))],
656            },
657        );
658        let mut persisted = serde_json::to_value(message).unwrap();
659        crate::utils::redact_json(&mut persisted);
660        let restored: ChatMessage = serde_json::from_value(persisted).unwrap();
661        let output = restored
662            .provider_continuation
663            .as_ref()
664            .and_then(ProviderContinuation::meta_output)
665            .unwrap();
666        assert_eq!(output[0].to_wire()["encrypted_content"], original);
667    }
668
669    #[test]
670    fn unknown_message_role_deserializes_to_system() {
671        // F74: a role string from a newer build must not fail the parse — it maps
672        // to the neutral System role so the conversation still loads (`--continue`
673        // no longer skips the newest session).
674        let role: MessageRole = serde_json::from_str("\"Developer\"").expect("tolerant");
675        assert_eq!(role, MessageRole::System);
676        // Known roles still map correctly.
677        assert_eq!(
678            serde_json::from_str::<MessageRole>("\"Tool\"").unwrap(),
679            MessageRole::Tool
680        );
681    }
682
683    #[test]
684    fn unknown_message_kind_deserializes_to_unknown() {
685        // F74: an unknown ChatMessageKind maps to Unknown via #[serde(other)]
686        // rather than failing the parse; it's treated like a normal message.
687        let kind: ChatMessageKind = serde_json::from_str("\"some_future_kind\"").expect("tolerant");
688        assert_eq!(kind, ChatMessageKind::Unknown);
689        assert_ne!(kind, ChatMessageKind::Normal);
690    }
691
692    #[test]
693    fn continuation_kinds_round_trip_through_serde() {
694        // The stitch markers must survive session save/load: a reloaded
695        // transcript stitches exactly like the live one did.
696        for kind in [
697            ChatMessageKind::Continuation,
698            ChatMessageKind::RecoveryNudge,
699        ] {
700            let json = serde_json::to_string(&kind).unwrap();
701            let back: ChatMessageKind = serde_json::from_str(&json).unwrap();
702            assert_eq!(back, kind);
703        }
704        // And the snake_case wire form is stable.
705        assert_eq!(
706            serde_json::to_string(&ChatMessageKind::Continuation).unwrap(),
707            "\"continuation\""
708        );
709        assert_eq!(
710            serde_json::to_string(&ChatMessageKind::RecoveryNudge).unwrap(),
711            "\"recovery_nudge\""
712        );
713    }
714
715    #[test]
716    fn chat_message_with_unknown_role_round_trips() {
717        // The whole ChatMessage deserialize succeeds despite an unknown role.
718        let json = r#"{
719            "role": "Developer",
720            "content": "hi",
721            "timestamp": "2026-04-16T12:00:00-04:00"
722        }"#;
723        let msg: ChatMessage = serde_json::from_str(json).expect("tolerant");
724        assert_eq!(msg.role, MessageRole::System);
725        assert_eq!(msg.content, "hi");
726    }
727
728    #[test]
729    fn extract_thinking_in_progress_no_end_marker() {
730        let raw = "Thinking...\n  partial reasoning so far";
731        let (thinking, answer) = ChatMessage::extract_thinking(raw);
732        assert_eq!(thinking.as_deref(), Some("partial reasoning so far"));
733        assert_eq!(answer, "");
734    }
735
736    #[test]
737    fn test_model_response_creation() {
738        let usage = TokenUsage::provider(100, 50);
739
740        let response = ModelResponse {
741            content: "Hello, world!".to_string(),
742            usage: Some(usage),
743            model_name: "ollama/tinyllama".to_string(),
744            thinking: None,
745            tool_calls: None,
746            stop_reason: None,
747            provider_continuation: None,
748        };
749
750        assert_eq!(response.content, "Hello, world!");
751        assert!(response.usage.is_some());
752        assert_eq!(response.model_name, "ollama/tinyllama");
753        assert_eq!(response.usage.unwrap().total_tokens(), 150);
754        assert!(response.tool_calls.is_none());
755    }
756}