Skip to main content

zeph_llm/
provider.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::future::Future;
5use std::pin::Pin;
6use std::{
7    any::TypeId,
8    collections::HashMap,
9    sync::{LazyLock, Mutex},
10};
11
12use futures_core::Stream;
13use serde::{Deserialize, Serialize};
14
15use zeph_common::ToolName;
16
17pub use zeph_common::ToolDefinition;
18
19use crate::embed::owned_strs;
20use crate::error::LlmError;
21
22static SCHEMA_CACHE: LazyLock<Mutex<HashMap<TypeId, (serde_json::Value, String)>>> =
23    LazyLock::new(|| Mutex::new(HashMap::new()));
24
25/// Return the JSON schema value and pretty-printed string for type `T`, cached by `TypeId`.
26///
27/// # Errors
28///
29/// Returns an error if schema serialization fails.
30pub(crate) fn cached_schema<T: schemars::JsonSchema + 'static>()
31-> Result<(serde_json::Value, String), crate::LlmError> {
32    let type_id = TypeId::of::<T>();
33    if let Ok(cache) = SCHEMA_CACHE.lock()
34        && let Some(entry) = cache.get(&type_id)
35    {
36        return Ok(entry.clone());
37    }
38    let schema = schemars::schema_for!(T);
39    let value = serde_json::to_value(&schema)
40        .map_err(|e| crate::LlmError::StructuredParse(e.to_string()))?;
41    let pretty = serde_json::to_string_pretty(&schema)
42        .map_err(|e| crate::LlmError::StructuredParse(e.to_string()))?;
43    if let Ok(mut cache) = SCHEMA_CACHE.lock() {
44        cache.insert(type_id, (value.clone(), pretty.clone()));
45    }
46    Ok((value, pretty))
47}
48
49/// Extract the short (unqualified) type name for schema prompts and tool names.
50///
51/// Returns the last `::` segment of [`std::any::type_name::<T>()`], which is always
52/// non-empty. The `"Output"` fallback is unreachable in practice (`type_name` never returns
53/// an empty string and `rsplit` on a non-empty string always yields at least one element),
54/// but is kept for defensive clarity.
55///
56/// # Examples
57///
58/// ```
59/// struct MyOutput;
60/// // short_type_name::<MyOutput>() returns "MyOutput"
61/// ```
62pub(crate) fn short_type_name<T: ?Sized>() -> &'static str {
63    std::any::type_name::<T>()
64        .rsplit("::")
65        .next()
66        .unwrap_or("Output")
67}
68
69/// Per-call extras returned alongside the chat response by [`LlmProvider::chat_with_extras`].
70///
71/// Always paired 1:1 with a single response — no shared state, no races possible.
72/// All optional fields default to `None` so providers that do not expose the
73/// underlying API (e.g. Claude, Gemini) can simply return the default.
74///
75/// Marked `#[non_exhaustive]` so future fields (e.g. `cached_tokens`) can be added
76/// without breaking match sites.
77#[non_exhaustive]
78#[derive(Debug, Clone, Default)]
79pub struct ChatExtras {
80    /// Mean negative log-probability of the generated tokens, when the provider
81    /// was configured to request `logprobs` and the API supplied them.
82    ///
83    /// Lower = more confident. Typical range: `[0.0, ~6.0]` for natural-language tokens.
84    pub entropy: Option<f64>,
85}
86
87impl ChatExtras {
88    /// Return a `ChatExtras` with the given entropy value.
89    ///
90    /// Used by `MockProvider` (test-only, enabled via `testing` feature) and OpenAI/Ollama providers.
91    ///
92    /// # Examples
93    ///
94    /// ```
95    /// use zeph_llm::provider::ChatExtras;
96    ///
97    /// let extras = ChatExtras::with_entropy(0.9);
98    /// assert_eq!(extras.entropy, Some(0.9));
99    /// ```
100    #[must_use]
101    pub fn with_entropy(entropy: f64) -> Self {
102        Self {
103            entropy: Some(entropy),
104        }
105    }
106}
107
108/// A chunk from an LLM streaming response.
109///
110/// Consumers should match all variants: future providers may emit non-`Content` chunks
111/// that callers must not silently drop (e.g. thinking blocks that must be echoed back).
112#[non_exhaustive]
113#[derive(Debug, Clone)]
114pub enum StreamChunk {
115    /// Regular response text.
116    Content(String),
117    /// Internal reasoning/thinking token (e.g. Claude extended thinking, `OpenAI` reasoning).
118    Thinking(String),
119    /// Server-side compaction summary (Claude compact-2026-01-12 beta).
120    /// Delivered when the Claude API automatically summarizes conversation history.
121    Compaction(String),
122    /// One or more tool calls from the model received during streaming.
123    ToolUse(Vec<ToolUseRequest>),
124}
125
126/// Boxed stream of typed chunks from an LLM provider.
127///
128/// Obtain via [`LlmProvider::chat_stream`]. Drive the stream with
129/// `futures::StreamExt::next` or `tokio_stream::StreamExt::next`.
130pub type ChatStream = Pin<Box<dyn Stream<Item = Result<StreamChunk, LlmError>> + Send>>;
131
132/// Structured tool invocation request from the model.
133///
134/// Returned by [`LlmProvider::chat_with_tools`] when the model decides to call one or
135/// more tools. The caller is responsible for executing the tool and returning results
136/// via a [`MessagePart::ToolResult`] in the next turn.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct ToolUseRequest {
139    /// Opaque call identifier assigned by the model; must be echoed in `ToolResult.tool_use_id`.
140    pub id: String,
141    /// Name of the tool to invoke, matching a [`ToolDefinition::name`].
142    pub name: ToolName,
143    /// JSON arguments the model wants to pass to the tool.
144    pub input: serde_json::Value,
145}
146
147/// Thinking block returned by Claude when extended or adaptive thinking is enabled.
148///
149/// Both variants must be echoed verbatim in the next turn's `assistant` message so
150/// the API can correctly attribute reasoning across turns. Never modify or discard
151/// these blocks between turns.
152#[non_exhaustive]
153#[derive(Debug, Clone)]
154pub enum ThinkingBlock {
155    /// Visible reasoning token with its cryptographic signature.
156    Thinking { thinking: String, signature: String },
157    /// Redacted reasoning block (API-side privacy redaction). Preserved as opaque data.
158    Redacted { data: String },
159}
160
161/// Marker injected into `ChatResponse::Text` when the LLM response was cut off by the
162/// token limit. Consumers can detect this substring to signal `MaxTokens` stop reason.
163pub const MAX_TOKENS_TRUNCATION_MARKER: &str = "max_tokens limit reached";
164
165/// Response from [`LlmProvider::chat_with_tools`].
166///
167/// When the model returns `ToolUse`, the caller must:
168/// 1. Execute each tool in `tool_calls`.
169/// 2. Append an `assistant` message with the original `tool_calls` and any `thinking_blocks`.
170/// 3. Append a `user` message containing [`MessagePart::ToolResult`] entries.
171/// 4. Call `chat_with_tools` again to continue the conversation.
172#[non_exhaustive]
173#[derive(Debug, Clone)]
174pub enum ChatResponse {
175    /// Model produced text output only.
176    Text(String),
177    /// Model requests one or more tool invocations.
178    ToolUse {
179        /// Any text the model emitted before/alongside tool calls.
180        text: Option<String>,
181        tool_calls: Vec<ToolUseRequest>,
182        /// Thinking blocks from the model (empty when thinking is disabled).
183        /// Must be preserved verbatim in multi-turn requests.
184        thinking_blocks: Vec<ThinkingBlock>,
185    },
186}
187
188/// Boxed future returning an embedding vector, returned by [`EmbedFn`].
189pub type EmbedFuture = Pin<Box<dyn Future<Output = Result<Vec<f32>, LlmError>> + Send>>;
190
191/// A Send + Sync closure that embeds a text slice into a vector.
192///
193/// Obtain a provider-backed `EmbedFn` via [`crate::any::AnyProvider::embed_fn`].
194/// The closure captures an `Arc`-wrapped provider clone, so it is cheap to clone.
195pub type EmbedFn = Box<dyn Fn(&str) -> EmbedFuture + Send + Sync>;
196
197/// Sender for emitting human-readable status events (retries, fallbacks) to the UI layer.
198///
199/// When set on a provider, the provider sends short strings such as
200/// `"Retrying after rate limit…"` or `"Falling back to secondary provider"`.
201/// The TUI consumes these to show real-time activity spinners.
202pub type StatusTx = tokio::sync::mpsc::UnboundedSender<String>;
203
204/// Best-effort fallback for debug dump request payloads when a provider does not expose
205/// its concrete API request body.
206#[must_use]
207pub fn default_debug_request_json(
208    messages: &[Message],
209    tools: &[ToolDefinition],
210) -> serde_json::Value {
211    serde_json::json!({
212        "model": serde_json::Value::Null,
213        "max_tokens": serde_json::Value::Null,
214        "messages": serde_json::to_value(messages).unwrap_or(serde_json::Value::Array(vec![])),
215        "tools": serde_json::to_value(tools).unwrap_or(serde_json::Value::Array(vec![])),
216        "temperature": serde_json::Value::Null,
217        "cache_control": serde_json::Value::Null,
218    })
219}
220
221/// Partial LLM generation parameter overrides for experiment variation injection.
222///
223/// Applied by the experiment engine to clone-and-patch a provider before evaluation,
224/// so each variation is scored with its specific generation parameters.
225///
226/// Only `Some` fields are applied; `None` fields leave the provider's configured
227/// defaults unchanged. Not all providers support all fields — unsupported fields
228/// are silently ignored by each backend.
229#[derive(Debug, Clone, Default)]
230pub struct GenerationOverrides {
231    /// Sampling temperature in `[0.0, 2.0]`. Lower = more deterministic.
232    pub temperature: Option<f64>,
233    /// Nucleus sampling probability in `[0.0, 1.0]`.
234    pub top_p: Option<f64>,
235    /// Top-K sampling cutoff (number of top tokens to consider).
236    pub top_k: Option<usize>,
237    /// Penalty for tokens that have already appeared (OpenAI-compatible providers).
238    pub frequency_penalty: Option<f64>,
239    /// Penalty for topics the model has already covered (OpenAI-compatible providers).
240    pub presence_penalty: Option<f64>,
241}
242
243/// Message role in a conversation.
244///
245/// Determines how each message is presented to the model:
246/// - `System` — global instructions prepended before the conversation
247/// - `User` — human turn input
248/// - `Assistant` — previous model output (used for multi-turn context)
249#[non_exhaustive]
250#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "lowercase")]
252pub enum Role {
253    System,
254    User,
255    Assistant,
256}
257
258/// A typed content part within a [`Message`].
259///
260/// Messages may contain zero or more parts that represent heterogeneous content:
261/// plain text, tool invocations, memory recall fragments, images, and internal
262/// protocol blocks (thinking, compaction). Most providers flatten these into a single
263/// string before sending; Claude encodes them as structured content blocks.
264///
265/// # Ordering invariants
266///
267/// - `ToolUse` parts must precede their corresponding `ToolResult` parts.
268/// - `ThinkingBlock` / `RedactedThinkingBlock` parts must be preserved verbatim in
269///   multi-turn requests so the API can correctly attribute reasoning.
270/// - `Compaction` parts must be preserved verbatim; the API uses them to prune
271///   prior history on subsequent turns (Claude compact-2026-01-12 beta).
272#[non_exhaustive]
273#[derive(Clone, Debug, Serialize, Deserialize)]
274#[serde(tag = "kind", rename_all = "snake_case")]
275pub enum MessagePart {
276    /// Plain assistant or user text.
277    Text { text: String },
278    /// Output from a tool execution, optionally compacted.
279    ToolOutput {
280        tool_name: zeph_common::ToolName,
281        body: String,
282        #[serde(default, skip_serializing_if = "Option::is_none")]
283        compacted_at: Option<i64>,
284    },
285    /// Memory recall fragment injected by the agent's semantic memory layer.
286    Recall { text: String },
287    /// Repository or file code context injected by the code indexing layer.
288    CodeContext { text: String },
289    /// Compaction summary replacing pruned conversation history.
290    Summary { text: String },
291    /// Cross-session memory fragment carried over from a previous conversation.
292    CrossSession { text: String },
293    /// Model-initiated tool invocation. Pairs with a subsequent [`MessagePart::ToolResult`].
294    ToolUse {
295        id: String,
296        name: String,
297        input: serde_json::Value,
298    },
299    /// Tool execution result returned to the model after a [`MessagePart::ToolUse`].
300    ToolResult {
301        tool_use_id: String,
302        content: String,
303        #[serde(default)]
304        is_error: bool,
305    },
306    /// Inline image payload (vision input).
307    Image(Box<ImageData>),
308    /// Claude thinking block — must be preserved verbatim in multi-turn requests.
309    ThinkingBlock { thinking: String, signature: String },
310    /// Claude redacted thinking block — preserved as-is in multi-turn requests.
311    RedactedThinkingBlock { data: String },
312    /// Claude server-side compaction block — must be preserved verbatim in multi-turn requests
313    /// so the API can correctly prune prior history on the next turn.
314    Compaction { summary: String },
315}
316
317impl MessagePart {
318    /// Return the plain text content if this part is a text-like variant (`Text`, `Recall`,
319    /// `CodeContext`, `Summary`, `CrossSession`), `None` otherwise.
320    #[must_use]
321    pub fn as_plain_text(&self) -> Option<&str> {
322        match self {
323            Self::Text { text }
324            | Self::Recall { text }
325            | Self::CodeContext { text }
326            | Self::Summary { text }
327            | Self::CrossSession { text } => Some(text.as_str()),
328            _ => None,
329        }
330    }
331
332    /// Return the image data if this part is an `Image` variant, `None` otherwise.
333    #[must_use]
334    pub fn as_image(&self) -> Option<&ImageData> {
335        if let Self::Image(img) = self {
336            Some(img)
337        } else {
338            None
339        }
340    }
341
342    /// Return a cloned copy of `parts` with every `Image` entry removed.
343    ///
344    /// `Image` parts are ephemeral, current-turn-only vision input (spec-072 §4, C1) and must
345    /// never reach a persistence sink — `SQLite` `parts_json`, the Qdrant embed path, the durable
346    /// JSONL session log, or a sub-agent transcript file. Callers apply this once, immediately
347    /// above the persistence write, leaving the in-memory slice used for the current turn's
348    /// provider request untouched.
349    ///
350    /// # Examples
351    ///
352    /// ```
353    /// use zeph_llm::provider::{ImageData, MessagePart};
354    ///
355    /// let parts = vec![
356    ///     MessagePart::Text {
357    ///         text: "hello".to_owned(),
358    ///     },
359    ///     MessagePart::Image(Box::new(ImageData {
360    ///         data: vec![0xFF, 0xD8, 0xFF, 0xE0],
361    ///         mime_type: "image/jpeg".to_owned(),
362    ///     })),
363    /// ];
364    /// let stripped = MessagePart::strip_images(&parts);
365    /// assert_eq!(stripped.len(), 1);
366    /// assert!(!stripped.iter().any(|p| matches!(p, MessagePart::Image(_))));
367    /// ```
368    #[must_use]
369    pub fn strip_images(parts: &[MessagePart]) -> Vec<MessagePart> {
370        parts
371            .iter()
372            .filter(|p| !matches!(p, MessagePart::Image(_)))
373            .cloned()
374            .collect()
375    }
376}
377
378#[derive(Clone, Serialize, Deserialize)]
379/// Raw image payload for vision-capable providers.
380///
381/// The `data` field is serialized as a Base64 string. `mime_type` must be a valid
382/// image MIME type supported by the target provider (e.g. `"image/png"`, `"image/jpeg"`).
383pub struct ImageData {
384    #[serde(with = "serde_bytes_base64")]
385    pub data: Vec<u8>,
386    pub mime_type: String,
387}
388
389impl std::fmt::Debug for ImageData {
390    /// Redacts the raw image bytes — only the MIME type and byte count are printed.
391    ///
392    /// `data` can carry arbitrary externally-sourced bytes (MCP tool results, user uploads);
393    /// a derived `Debug` would dump the full payload into logs/panics.
394    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395        write!(f, "[image: {}, {} bytes]", self.mime_type, self.data.len())
396    }
397}
398
399mod serde_bytes_base64 {
400    use base64::{Engine, engine::general_purpose::STANDARD};
401    use serde::{Deserialize, Deserializer, Serializer};
402
403    pub fn serialize<S>(bytes: &[u8], s: S) -> Result<S::Ok, S::Error>
404    where
405        S: Serializer,
406    {
407        s.serialize_str(&STANDARD.encode(bytes))
408    }
409
410    pub fn deserialize<'de, D>(d: D) -> Result<Vec<u8>, D::Error>
411    where
412        D: Deserializer<'de>,
413    {
414        let s = String::deserialize(d)?;
415        STANDARD.decode(&s).map_err(serde::de::Error::custom)
416    }
417}
418
419/// Visibility of a message to agent and user.
420///
421/// Replaces the former `(agent_visible: bool, user_visible: bool)` pair, which
422/// allowed the semantically invalid `(false, false)` combination. Every variant
423/// guarantees at least one consumer can see the message.
424///
425/// # Examples
426///
427/// ```
428/// use zeph_llm::provider::MessageVisibility;
429///
430/// let v = MessageVisibility::AgentOnly;
431/// assert!(v.is_agent_visible());
432/// assert!(!v.is_user_visible());
433/// ```
434#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
435#[serde(rename_all = "snake_case")]
436#[non_exhaustive]
437pub enum MessageVisibility {
438    /// Visible to both the agent (LLM context) and the user (conversation log).
439    Both,
440    /// Visible to the agent only (e.g. compaction summaries, internal context).
441    AgentOnly,
442    /// Visible to the user only (e.g. compacted originals shown in history).
443    UserOnly,
444}
445
446impl MessageVisibility {
447    /// Returns `true` if this message should be included in the LLM request context.
448    #[must_use]
449    pub fn is_agent_visible(self) -> bool {
450        matches!(self, MessageVisibility::Both | MessageVisibility::AgentOnly)
451    }
452
453    /// Returns `true` if this message should appear in the user-facing conversation log.
454    #[must_use]
455    pub fn is_user_visible(self) -> bool {
456        matches!(self, MessageVisibility::Both | MessageVisibility::UserOnly)
457    }
458}
459
460impl Default for MessageVisibility {
461    /// Defaults to [`Both`](MessageVisibility::Both) — visible to agent and user.
462    fn default() -> Self {
463        MessageVisibility::Both
464    }
465}
466
467impl MessageVisibility {
468    /// Serialize to the SQLite/PostgreSQL text value stored in the `visibility` column.
469    #[must_use]
470    pub fn as_db_str(self) -> &'static str {
471        match self {
472            MessageVisibility::Both => "both",
473            MessageVisibility::AgentOnly => "agent_only",
474            MessageVisibility::UserOnly => "user_only",
475        }
476    }
477
478    /// Deserialize from the SQLite/PostgreSQL text value stored in the `visibility` column.
479    ///
480    /// Unknown values (e.g. from a future migration) default to `Both` for safety.
481    #[must_use]
482    pub fn from_db_str(s: &str) -> Self {
483        match s {
484            "agent_only" => MessageVisibility::AgentOnly,
485            "user_only" => MessageVisibility::UserOnly,
486            _ => MessageVisibility::Both,
487        }
488    }
489}
490
491/// Per-message visibility and metadata controlling agent context and user display.
492///
493/// Constructors [`agent_only`](Self::agent_only), [`user_only`](Self::user_only),
494/// and [`focus_pinned`](Self::focus_pinned) cover the most common combinations.
495#[derive(Clone, Debug, Serialize, Deserialize)]
496pub struct MessageMetadata {
497    /// Who can see this message.
498    pub visibility: MessageVisibility,
499    /// Unix timestamp (seconds) when this message was compacted, if applicable.
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    pub compacted_at: Option<i64>,
502    /// Pre-computed tool pair summary, applied lazily when context pressure rises.
503    /// Stored on the tool response message; cleared after application.
504    #[serde(default, skip_serializing_if = "Option::is_none")]
505    pub deferred_summary: Option<String>,
506    /// When true, this message is excluded from all compaction passes (soft pruning,
507    /// hard summarization, sidequest eviction). Used for the Focus Knowledge block (#1850).
508    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
509    pub focus_pinned: bool,
510    /// Unique marker UUID set when `start_focus` begins a session. Used by `complete_focus`
511    /// to locate the checkpoint without relying on a fragile raw index.
512    #[serde(default, skip_serializing_if = "Option::is_none")]
513    pub focus_marker_id: Option<uuid::Uuid>,
514    /// `SQLite` row ID for this message. Populated when loading from DB or after persisting.
515    /// Never serialized — always re-populated from the database on load.
516    #[serde(skip)]
517    pub db_id: Option<i64>,
518    /// Fidelity level assigned by `FidelityScorer` during context assembly.
519    ///
520    /// `None` when fidelity scoring is disabled or the message has not yet been scored.
521    /// Used for debug tracing and compaction input filtering (INV-02).
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub fidelity_tag: Option<zeph_common::ContextFidelity>,
524    /// Cached embedding vector for semantic fidelity scoring.
525    ///
526    /// In-memory only — not serialized or persisted to the database.
527    #[serde(skip)]
528    pub embedding: Option<Vec<f32>>,
529    /// Write-time memory-consent gate content-trust tier (issue #6490 `MemGhost`, #6558 fix).
530    ///
531    /// Raw `u8` discriminant of `zeph_sanitizer::ContentTrustLevel` (kept as a plain integer
532    /// here, not the enum itself, since `zeph-llm` does not depend on `zeph-sanitizer` — mirrors
533    /// `zeph_core::memory_tools::MemoryConsentTrustSlot`'s same representation choice). Set on
534    /// tool-result batch messages by `Agent::process_tool_result_batch` to the batch's
535    /// worst-case trust tier. Scanned by `Agent::context_max_trust_level` so untrusted content
536    /// is still recognized by the memory-consent gate for as long as it remains in the live
537    /// conversation context — including across a user-turn boundary — rather than only for the
538    /// single turn in which it was fetched.
539    #[serde(default, skip_serializing_if = "Option::is_none")]
540    pub trust_level: Option<u8>,
541}
542
543impl Default for MessageMetadata {
544    fn default() -> Self {
545        Self {
546            visibility: MessageVisibility::Both,
547            compacted_at: None,
548            deferred_summary: None,
549            focus_pinned: false,
550            focus_marker_id: None,
551            db_id: None,
552            fidelity_tag: None,
553            embedding: None,
554            trust_level: None,
555        }
556    }
557}
558
559impl MessageMetadata {
560    /// Message visible only to the agent (e.g. compaction summary).
561    #[must_use]
562    pub fn agent_only() -> Self {
563        Self {
564            visibility: MessageVisibility::AgentOnly,
565            compacted_at: None,
566            deferred_summary: None,
567            focus_pinned: false,
568            focus_marker_id: None,
569            db_id: None,
570            fidelity_tag: None,
571            embedding: None,
572            trust_level: None,
573        }
574    }
575
576    /// Message visible only to the user (e.g. compacted original).
577    #[must_use]
578    pub fn user_only() -> Self {
579        Self {
580            visibility: MessageVisibility::UserOnly,
581            compacted_at: None,
582            deferred_summary: None,
583            focus_pinned: false,
584            focus_marker_id: None,
585            db_id: None,
586            fidelity_tag: None,
587            embedding: None,
588            trust_level: None,
589        }
590    }
591
592    /// Pinned Knowledge block — excluded from all compaction passes.
593    #[must_use]
594    pub fn focus_pinned() -> Self {
595        Self {
596            visibility: MessageVisibility::AgentOnly,
597            compacted_at: None,
598            deferred_summary: None,
599            focus_pinned: true,
600            focus_marker_id: None,
601            db_id: None,
602            fidelity_tag: None,
603            embedding: None,
604            trust_level: None,
605        }
606    }
607}
608
609/// A single message in a conversation.
610///
611/// Each message has a [`Role`], a flat `content` string (used when sending to providers
612/// that do not support structured parts), and an optional list of [`MessagePart`]s for
613/// providers that accept heterogeneous content blocks (e.g. Claude).
614///
615/// The `content` field is kept in sync with `parts` via [`Message::rebuild_content`].
616/// When building messages from structured parts, always use [`Message::from_parts`] —
617/// it populates both `parts` and `content`.
618///
619/// # Examples
620///
621/// ```
622/// use zeph_llm::provider::{Message, MessagePart, Role};
623///
624/// // Simple text-only message
625/// let msg = Message::from_legacy(Role::User, "What is Rust?");
626/// assert_eq!(msg.to_llm_content(), "What is Rust?");
627///
628/// // Structured message with parts
629/// let parts = vec![
630///     MessagePart::Text { text: "Explain this code.".into() },
631/// ];
632/// let msg = Message::from_parts(Role::User, parts);
633/// assert!(!msg.parts.is_empty());
634/// ```
635#[derive(Clone, Debug, Serialize, Deserialize)]
636pub struct Message {
637    pub role: Role,
638    /// Flat text representation of this message, derived from `parts` when structured.
639    pub content: String,
640    #[serde(default)]
641    pub parts: Vec<MessagePart>,
642    #[serde(default)]
643    pub metadata: MessageMetadata,
644}
645
646impl Default for Message {
647    fn default() -> Self {
648        Self {
649            role: Role::User,
650            content: String::new(),
651            parts: vec![],
652            metadata: MessageMetadata::default(),
653        }
654    }
655}
656
657impl Message {
658    /// Create a simple text-only message without structured parts.
659    ///
660    /// Use this constructor for system prompts, plain user turns, and assistant
661    /// messages produced by providers that return a raw string.
662    #[must_use]
663    pub fn from_legacy(role: Role, content: impl Into<String>) -> Self {
664        Self {
665            role,
666            content: content.into(),
667            parts: vec![],
668            metadata: MessageMetadata::default(),
669        }
670    }
671
672    /// Create a message from structured parts, deriving the flat `content` automatically.
673    ///
674    /// Prefer this constructor when the message contains tool invocations, images,
675    /// or other non-text content that providers need to render as separate content blocks.
676    #[must_use]
677    pub fn from_parts(role: Role, parts: Vec<MessagePart>) -> Self {
678        let content = Self::flatten_parts(&parts);
679        Self {
680            role,
681            content,
682            parts,
683            metadata: MessageMetadata::default(),
684        }
685    }
686
687    /// Return the flat text content of this message, suitable for providers that do
688    /// not support structured content blocks.
689    #[must_use]
690    pub fn to_llm_content(&self) -> &str {
691        &self.content
692    }
693
694    /// Re-synchronize `content` from `parts` after in-place mutation.
695    pub fn rebuild_content(&mut self) {
696        if !self.parts.is_empty() {
697            self.content = Self::flatten_parts(&self.parts);
698        }
699    }
700
701    fn flatten_parts(parts: &[MessagePart]) -> String {
702        use std::fmt::Write;
703        let mut out = String::new();
704        for part in parts {
705            match part {
706                MessagePart::Text { text }
707                | MessagePart::Recall { text }
708                | MessagePart::CodeContext { text }
709                | MessagePart::Summary { text }
710                | MessagePart::CrossSession { text } => out.push_str(text),
711                MessagePart::ToolOutput {
712                    tool_name,
713                    body,
714                    compacted_at,
715                } => {
716                    if compacted_at.is_some() {
717                        if body.is_empty() {
718                            let _ = write!(out, "[tool output: {tool_name}] (pruned)");
719                        } else {
720                            let _ = write!(out, "[tool output: {tool_name}] {body}");
721                        }
722                    } else {
723                        let _ = write!(out, "[tool output: {tool_name}]\n```\n{body}\n```");
724                    }
725                }
726                MessagePart::ToolUse { id, name, .. } => {
727                    let _ = write!(out, "[tool_use: {name}({id})]");
728                }
729                MessagePart::ToolResult {
730                    tool_use_id,
731                    content,
732                    ..
733                } => {
734                    let _ = write!(out, "[tool_result: {tool_use_id}]\n{content}");
735                }
736                MessagePart::Image(img) => {
737                    let _ = write!(out, "[image: {}, {} bytes]", img.mime_type, img.data.len());
738                }
739                // Thinking and compaction blocks are internal API metadata — not rendered in text.
740                MessagePart::ThinkingBlock { .. }
741                | MessagePart::RedactedThinkingBlock { .. }
742                | MessagePart::Compaction { .. } => {}
743            }
744        }
745        out
746    }
747}
748
749/// Core abstraction for all LLM inference backends.
750///
751/// Every backend — `Ollama`, `Claude`, `OpenAI`, `Gemini`, `Candle` — implements this trait.
752/// The [`crate::any::AnyProvider`] enum erases the concrete type so callers can
753/// hold any backend behind a single type, and [`crate::router::RouterProvider`]
754/// implements this trait to multiplex across multiple backends.
755///
756/// # Object safety
757///
758/// This trait is **not** object-safe: 6 methods return `impl Future + Send` (RPIT),
759/// and [`chat_typed`](Self::chat_typed) carries a generic type parameter `T`.
760/// The `where Self: Sized` bound on `chat_typed` is a mitigation — it excludes that
761/// method from the vtable — but the RPIT methods remain and prevent `dyn LlmProvider`.
762/// Attempting `Box<dyn LlmProvider>` will produce a compile error.
763///
764/// For dynamic dispatch, use [`Arc<dyn LlmProviderDyn>`](crate::provider_dyn::LlmProviderDyn)
765/// instead — a blanket impl wires every `LlmProvider` implementor automatically.
766/// See the [`provider_dyn`](crate::provider_dyn) module for details.
767///
768/// # Required methods
769///
770/// Implementors must provide: [`chat`](Self::chat), [`chat_stream`](Self::chat_stream),
771/// [`supports_streaming`](Self::supports_streaming), [`embed`](Self::embed),
772/// [`supports_embeddings`](Self::supports_embeddings), and [`name`](Self::name).
773///
774/// # Optional methods
775///
776/// All other methods have default implementations that are safe to accept:
777/// - [`context_window`](Self::context_window) — returns `None`
778/// - [`embed_batch`](Self::embed_batch) — sequential fallback via [`embed`](Self::embed)
779/// - [`chat_with_tools`](Self::chat_with_tools) — falls back to [`chat`](Self::chat)
780/// - [`chat_typed`](Self::chat_typed) — schema-prompt injection + retry
781///   (requires `Self: Sized`; use [`chat_typed_dyn`](crate::provider_dyn::chat_typed_dyn)
782///   for trait objects)
783/// - [`supports_vision`](Self::supports_vision) — returns `false`
784/// - [`supports_tool_use`](Self::supports_tool_use) — returns `false`, matching the
785///   [`chat_with_tools`](Self::chat_with_tools) default fallback which silently drops
786///   tool definitions; providers must override both together to opt into tool use
787///
788/// # Examples
789///
790/// ```rust,no_run
791/// use zeph_llm::provider::{LlmProvider, Message, Role, ChatStream};
792/// use zeph_llm::LlmError;
793///
794/// struct EchoProvider;
795///
796/// impl LlmProvider for EchoProvider {
797///     async fn chat(&self, messages: &[Message]) -> Result<String, LlmError> {
798///         Ok(messages.last().map(|m| m.content.clone()).unwrap_or_default())
799///     }
800///
801///     async fn chat_stream(&self, messages: &[Message]) -> Result<ChatStream, LlmError> {
802///         use zeph_llm::provider::StreamChunk;
803///         let text = self.chat(messages).await?;
804///         Ok(Box::pin(tokio_stream::once(Ok(StreamChunk::Content(text)))))
805///     }
806///
807///     fn supports_streaming(&self) -> bool { true }
808///
809///     async fn embed(&self, _text: &str) -> Result<Vec<f32>, LlmError> {
810///         Err(LlmError::EmbedUnsupported { provider: "echo".into() })
811///     }
812///
813///     fn supports_embeddings(&self) -> bool { false }
814///
815///     fn name(&self) -> &str { "echo" }
816/// }
817/// ```
818pub trait LlmProvider: Send + Sync {
819    /// Report the model's context window size in tokens.
820    ///
821    /// Returns `None` if unknown. Used for auto-budget calculation.
822    fn context_window(&self) -> Option<usize> {
823        None
824    }
825
826    /// Send messages to the LLM and return the assistant response.
827    ///
828    /// # Errors
829    ///
830    /// Returns an error if the provider fails to communicate or the response is invalid.
831    fn chat(&self, messages: &[Message]) -> impl Future<Output = Result<String, LlmError>> + Send;
832
833    /// Send messages and return a stream of response chunks.
834    ///
835    /// # Errors
836    ///
837    /// Returns an error if the provider fails to communicate or the response is invalid.
838    fn chat_stream(
839        &self,
840        messages: &[Message],
841    ) -> impl Future<Output = Result<ChatStream, LlmError>> + Send;
842
843    /// Whether this provider supports native streaming.
844    fn supports_streaming(&self) -> bool;
845
846    /// Generate an embedding vector from text.
847    ///
848    /// # Errors
849    ///
850    /// Returns an error if the provider does not support embeddings or the request fails.
851    fn embed(&self, text: &str) -> impl Future<Output = Result<Vec<f32>, LlmError>> + Send;
852
853    /// Embed multiple texts in a single API call.
854    ///
855    /// Default implementation calls [`embed`][Self::embed] sequentially for each input.
856    /// Providers with native batch APIs should override this.
857    ///
858    /// # Errors
859    ///
860    /// Returns an error if any embedding fails. On native batch backends the entire batch
861    /// fails atomically; on the sequential fallback the first error aborts.
862    fn embed_batch(
863        &self,
864        texts: &[&str],
865    ) -> impl Future<Output = Result<Vec<Vec<f32>>, LlmError>> + Send {
866        let owned = owned_strs(texts);
867        async move {
868            let mut results = Vec::with_capacity(owned.len());
869            for text in &owned {
870                results.push(self.embed(text).await?);
871            }
872            Ok(results)
873        }
874    }
875
876    /// Whether this provider supports embedding generation.
877    fn supports_embeddings(&self) -> bool;
878
879    /// Provider name for logging and identification.
880    fn name(&self) -> &str;
881
882    /// Model identifier string (e.g. `gpt-4o-mini`, `claude-sonnet-5`).
883    /// Used by cost-estimation heuristics. Returns `""` when not applicable.
884    #[allow(clippy::unnecessary_literal_bound)]
885    fn model_identifier(&self) -> &str {
886        ""
887    }
888
889    /// Model identifier that actually served the most recent dispatch.
890    ///
891    /// For a concrete single-model provider (Claude, `OpenAI`, Candle, ...) this is
892    /// identical to [`model_identifier`](Self::model_identifier) — the default
893    /// implementation simply forwards to it. Routing providers (`Router`,
894    /// `TriageRouter`) override it to resolve the sub-provider that served the last
895    /// call, since their own `model_identifier()` returns a stable routing-policy
896    /// label (e.g. `"router"`) rather than a real model id. Callers that need to
897    /// reason about the model that produced a specific response (for example,
898    /// `is_reasoning_model` detection) should call this method instead of
899    /// `model_identifier()`.
900    fn effective_model_identifier(&self) -> &str {
901        self.model_identifier()
902    }
903
904    /// Whether this provider supports image input (vision).
905    fn supports_vision(&self) -> bool {
906        false
907    }
908
909    /// Whether this provider supports native `tool_use` / function calling.
910    ///
911    /// Defaults to `false` because [`chat_with_tools`](Self::chat_with_tools) defaults
912    /// to falling back on [`chat`](Self::chat), which silently discards tool
913    /// definitions. Providers implementing real tool calling must override both
914    /// this method and `chat_with_tools` together.
915    fn supports_tool_use(&self) -> bool {
916        false
917    }
918
919    /// Send messages with tool definitions, returning a structured response.
920    ///
921    /// Default: falls back to `chat()` and wraps the result in `ChatResponse::Text`.
922    ///
923    /// # Errors
924    ///
925    /// Returns an error if the provider fails to communicate or the response is invalid.
926    fn chat_with_tools(
927        &self,
928        messages: &[Message],
929        _tools: &[ToolDefinition],
930    ) -> impl std::future::Future<Output = Result<ChatResponse, LlmError>> + Send {
931        let msgs = messages.to_vec();
932        async move { Ok(ChatResponse::Text(self.chat(&msgs).await?)) }
933    }
934
935    /// Return the cache usage from the last API call, if available.
936    /// Returns `(cache_creation_tokens, cache_read_tokens)`.
937    fn last_cache_usage(&self) -> Option<(u64, u64)> {
938        None
939    }
940
941    /// Return token counts from the last API call, if available.
942    /// Returns `(input_tokens, output_tokens)`.
943    fn last_usage(&self) -> Option<(u64, u64)> {
944        None
945    }
946
947    /// Return reasoning tokens from the last API call, if the provider reports them.
948    ///
949    /// Reasoning tokens are a **subset** of completion tokens (`OpenAI` o-series only).
950    /// Returns `None` for providers that do not expose reasoning token counts.
951    fn last_reasoning_tokens(&self) -> Option<u64> {
952        None
953    }
954
955    /// Return the time-to-first-byte (milliseconds) of the last API call, if available.
956    ///
957    /// This trait method always returns a TTFB proxy (measured around the HTTP request send,
958    /// `retry::send_with_retry` or the equivalent per-attempt retry loop for providers that
959    /// don't use that shared helper) — it never reflects true time-to-first-token, even for
960    /// calls that streamed. `None` for providers with no HTTP round-trip (Candle) or that
961    /// have not made a call yet.
962    ///
963    /// The one production streaming path (speculative decoding) captures true
964    /// time-to-first-token separately, at its own stream-consumption point, and injects it
965    /// alongside (overriding) this method's TTFB value when building a `usage_records` row —
966    /// see `zeph_core::agent::speculative::stream_drainer::SpeculativeStreamDrainer::drive`.
967    /// This trait method itself is not aware of that override; callers that need the true
968    /// streaming value must go through that call site, not this one.
969    fn last_ttft_ms(&self) -> Option<u64> {
970        None
971    }
972
973    /// Return the compaction summary from the most recent API call, if a server-side
974    /// compaction occurred (Claude compact-2026-01-12 beta). Clears the stored value.
975    fn take_compaction_summary(&self) -> Option<String> {
976        None
977    }
978
979    /// Send messages and return the assistant response together with per-call extras.
980    ///
981    /// Default implementation calls [`chat`][Self::chat] and returns [`ChatExtras::default()`],
982    /// keeping every existing implementor source-compatible at zero cost.
983    ///
984    /// Providers that support logprobs (`OpenAI`, `Compatible`, `Ollama`) override this to
985    /// populate [`ChatExtras::entropy`] with the mean negative log-probability.
986    ///
987    /// `CoE` is the only caller of this method; the canonical entry point for the agent
988    /// loop remains [`chat`][Self::chat].
989    ///
990    /// # Errors
991    ///
992    /// Same as [`chat`][Self::chat].
993    fn chat_with_extras(
994        &self,
995        messages: &[Message],
996    ) -> impl Future<Output = Result<(String, ChatExtras), LlmError>> + Send {
997        let msgs = messages.to_vec();
998        async move { Ok((self.chat(&msgs).await?, ChatExtras::default())) }
999    }
1000
1001    /// Return the request payload that will be sent to the provider, for debug dumps.
1002    ///
1003    /// Implementations should mirror the provider's request body as closely as practical.
1004    #[must_use]
1005    fn debug_request_json(
1006        &self,
1007        messages: &[Message],
1008        tools: &[ToolDefinition],
1009        _stream: bool,
1010    ) -> serde_json::Value {
1011        default_debug_request_json(messages, tools)
1012    }
1013
1014    /// Return the list of model identifiers this provider can serve.
1015    /// Default: empty (provider does not advertise models).
1016    fn list_models(&self) -> Vec<String> {
1017        vec![]
1018    }
1019
1020    /// Whether this provider supports native structured output.
1021    fn supports_structured_output(&self) -> bool {
1022        false
1023    }
1024
1025    /// Send messages and parse the response into a typed value `T`.
1026    ///
1027    /// Default implementation injects JSON schema into the system prompt and retries once
1028    /// on parse failure. Providers with native structured output should override this.
1029    ///
1030    /// # Object safety
1031    ///
1032    /// This method requires `Self: Sized` and is therefore unavailable on trait objects.
1033    /// Use [`chat_typed_dyn`](crate::provider_dyn::chat_typed_dyn) when working with
1034    /// `Arc<dyn LlmProviderDyn>`.
1035    #[allow(async_fn_in_trait)]
1036    async fn chat_typed<T>(&self, messages: &[Message]) -> Result<T, LlmError>
1037    where
1038        T: serde::de::DeserializeOwned + schemars::JsonSchema + 'static,
1039        Self: Sized,
1040    {
1041        let (_, schema_json) = cached_schema::<T>()?;
1042        let type_name = short_type_name::<T>();
1043
1044        let mut augmented = messages.to_vec();
1045        let instruction = format!(
1046            "Respond with a valid JSON object matching this schema. \
1047             Output ONLY the JSON, no markdown fences or extra text.\n\n\
1048             Type: {type_name}\nSchema:\n```json\n{schema_json}\n```"
1049        );
1050        augmented.insert(0, Message::from_legacy(Role::System, instruction));
1051
1052        let raw = self.chat(&augmented).await?;
1053        let cleaned = strip_json_fences(&raw);
1054        match serde_json::from_str::<T>(cleaned) {
1055            Ok(val) => Ok(val),
1056            Err(first_err) => {
1057                augmented.push(Message::from_legacy(Role::Assistant, &raw));
1058                augmented.push(Message::from_legacy(
1059                    Role::User,
1060                    format!(
1061                        "Your response was not valid JSON. Error: {first_err}. \
1062                         Please output ONLY valid JSON matching the schema."
1063                    ),
1064                ));
1065                let retry_raw = self.chat(&augmented).await?;
1066                let retry_cleaned = strip_json_fences(&retry_raw);
1067                serde_json::from_str::<T>(retry_cleaned).map_err(|e| {
1068                    LlmError::StructuredParse(format!("parse failed after retry: {e}"))
1069                })
1070            }
1071        }
1072    }
1073}
1074
1075/// Strip markdown code fences from LLM output. Only handles outer fences;
1076/// JSON containing trailing triple backticks in string values may be
1077/// incorrectly trimmed (acceptable for MVP — see review R2).
1078fn strip_json_fences(s: &str) -> &str {
1079    s.trim()
1080        .trim_start_matches("```json")
1081        .trim_start_matches("```")
1082        .trim_end_matches("```")
1083        .trim()
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088    use std::assert_matches;
1089    use tokio_stream::StreamExt;
1090
1091    use super::*;
1092
1093    struct StubProvider {
1094        response: String,
1095    }
1096
1097    impl LlmProvider for StubProvider {
1098        async fn chat(&self, _messages: &[Message]) -> Result<String, LlmError> {
1099            Ok(self.response.clone())
1100        }
1101
1102        async fn chat_stream(&self, messages: &[Message]) -> Result<ChatStream, LlmError> {
1103            let response = self.chat(messages).await?;
1104            Ok(Box::pin(tokio_stream::once(Ok(StreamChunk::Content(
1105                response,
1106            )))))
1107        }
1108
1109        fn supports_streaming(&self) -> bool {
1110            false
1111        }
1112
1113        async fn embed(&self, _text: &str) -> Result<Vec<f32>, LlmError> {
1114            Ok(vec![0.1, 0.2, 0.3])
1115        }
1116
1117        fn supports_embeddings(&self) -> bool {
1118            false
1119        }
1120
1121        fn name(&self) -> &'static str {
1122            "stub"
1123        }
1124    }
1125
1126    #[test]
1127    fn test_image_data_debug_redacts_bytes() {
1128        let img = ImageData {
1129            data: vec![0xAB, 0xCD, 0xEF],
1130            mime_type: "image/png".to_owned(),
1131        };
1132        let debug = format!("{img:?}");
1133        assert_eq!(debug, "[image: image/png, 3 bytes]");
1134        assert!(!debug.contains("171") && !debug.contains("205") && !debug.contains("239"));
1135    }
1136
1137    #[test]
1138    fn context_window_default_returns_none() {
1139        let provider = StubProvider {
1140            response: String::new(),
1141        };
1142        assert!(provider.context_window().is_none());
1143    }
1144
1145    #[test]
1146    fn supports_streaming_default_returns_false() {
1147        let provider = StubProvider {
1148            response: String::new(),
1149        };
1150        assert!(!provider.supports_streaming());
1151    }
1152
1153    #[test]
1154    fn supports_tool_use_default_returns_false() {
1155        // StubProvider overrides neither `supports_tool_use` nor `chat_with_tools`,
1156        // mirroring CandleProvider (crates/zeph-llm/src/candle_provider/mod.rs). The
1157        // default must report `false` so callers gating on this method skip providers
1158        // that would otherwise silently drop tool definitions via the `chat_with_tools`
1159        // fallback (issue #5687).
1160        let provider = StubProvider {
1161            response: String::new(),
1162        };
1163        assert!(!provider.supports_tool_use());
1164    }
1165
1166    #[tokio::test]
1167    async fn chat_stream_default_yields_single_chunk() {
1168        let provider = StubProvider {
1169            response: "hello world".into(),
1170        };
1171        let messages = vec![Message {
1172            role: Role::User,
1173            content: "test".into(),
1174            parts: vec![],
1175            metadata: MessageMetadata::default(),
1176        }];
1177
1178        let mut stream = provider.chat_stream(&messages).await.unwrap();
1179        let chunk = stream.next().await.unwrap().unwrap();
1180        assert_matches!(chunk, StreamChunk::Content(s) if s == "hello world");
1181        assert!(stream.next().await.is_none());
1182    }
1183
1184    #[tokio::test]
1185    async fn chat_stream_default_propagates_chat_error() {
1186        struct FailProvider;
1187
1188        impl LlmProvider for FailProvider {
1189            async fn chat(&self, _messages: &[Message]) -> Result<String, LlmError> {
1190                Err(LlmError::Unavailable)
1191            }
1192
1193            async fn chat_stream(&self, messages: &[Message]) -> Result<ChatStream, LlmError> {
1194                let response = self.chat(messages).await?;
1195                Ok(Box::pin(tokio_stream::once(Ok(StreamChunk::Content(
1196                    response,
1197                )))))
1198            }
1199
1200            fn supports_streaming(&self) -> bool {
1201                false
1202            }
1203
1204            async fn embed(&self, _text: &str) -> Result<Vec<f32>, LlmError> {
1205                Err(LlmError::Unavailable)
1206            }
1207
1208            fn supports_embeddings(&self) -> bool {
1209                false
1210            }
1211
1212            fn name(&self) -> &'static str {
1213                "fail"
1214            }
1215        }
1216
1217        let provider = FailProvider;
1218        let messages = vec![Message {
1219            role: Role::User,
1220            content: "test".into(),
1221            parts: vec![],
1222            metadata: MessageMetadata::default(),
1223        }];
1224
1225        let result = provider.chat_stream(&messages).await;
1226        assert!(result.is_err());
1227        if let Err(e) = result {
1228            assert!(e.to_string().contains("provider unavailable"));
1229        }
1230    }
1231
1232    #[tokio::test]
1233    async fn stub_provider_embed_returns_vector() {
1234        let provider = StubProvider {
1235            response: String::new(),
1236        };
1237        let embedding = provider.embed("test").await.unwrap();
1238        assert_eq!(embedding, vec![0.1, 0.2, 0.3]);
1239    }
1240
1241    #[tokio::test]
1242    async fn fail_provider_embed_propagates_error() {
1243        struct FailProvider;
1244
1245        impl LlmProvider for FailProvider {
1246            async fn chat(&self, _messages: &[Message]) -> Result<String, LlmError> {
1247                Err(LlmError::Unavailable)
1248            }
1249
1250            async fn chat_stream(&self, messages: &[Message]) -> Result<ChatStream, LlmError> {
1251                let response = self.chat(messages).await?;
1252                Ok(Box::pin(tokio_stream::once(Ok(StreamChunk::Content(
1253                    response,
1254                )))))
1255            }
1256
1257            fn supports_streaming(&self) -> bool {
1258                false
1259            }
1260
1261            async fn embed(&self, _text: &str) -> Result<Vec<f32>, LlmError> {
1262                Err(LlmError::EmbedUnsupported {
1263                    provider: "fail".into(),
1264                })
1265            }
1266
1267            fn supports_embeddings(&self) -> bool {
1268                false
1269            }
1270
1271            fn name(&self) -> &'static str {
1272                "fail"
1273            }
1274        }
1275
1276        let provider = FailProvider;
1277        let result = provider.embed("test").await;
1278        assert!(result.is_err());
1279        assert!(
1280            result
1281                .unwrap_err()
1282                .to_string()
1283                .contains("embedding not supported")
1284        );
1285    }
1286
1287    #[test]
1288    fn role_serialization() {
1289        let system = Role::System;
1290        let user = Role::User;
1291        let assistant = Role::Assistant;
1292
1293        assert_eq!(serde_json::to_string(&system).unwrap(), "\"system\"");
1294        assert_eq!(serde_json::to_string(&user).unwrap(), "\"user\"");
1295        assert_eq!(serde_json::to_string(&assistant).unwrap(), "\"assistant\"");
1296    }
1297
1298    #[test]
1299    fn role_deserialization() {
1300        let system: Role = serde_json::from_str("\"system\"").unwrap();
1301        let user: Role = serde_json::from_str("\"user\"").unwrap();
1302        let assistant: Role = serde_json::from_str("\"assistant\"").unwrap();
1303
1304        assert_eq!(system, Role::System);
1305        assert_eq!(user, Role::User);
1306        assert_eq!(assistant, Role::Assistant);
1307    }
1308
1309    #[test]
1310    fn message_clone() {
1311        let msg = Message {
1312            role: Role::User,
1313            content: "test".into(),
1314            parts: vec![],
1315            metadata: MessageMetadata::default(),
1316        };
1317        let cloned = msg.clone();
1318        assert_eq!(cloned.role, msg.role);
1319        assert_eq!(cloned.content, msg.content);
1320    }
1321
1322    #[test]
1323    fn message_debug() {
1324        let msg = Message {
1325            role: Role::Assistant,
1326            content: "response".into(),
1327            parts: vec![],
1328            metadata: MessageMetadata::default(),
1329        };
1330        let debug = format!("{msg:?}");
1331        assert!(debug.contains("Assistant"));
1332        assert!(debug.contains("response"));
1333    }
1334
1335    #[test]
1336    fn message_serialization() {
1337        let msg = Message {
1338            role: Role::User,
1339            content: "hello".into(),
1340            parts: vec![],
1341            metadata: MessageMetadata::default(),
1342        };
1343        let json = serde_json::to_string(&msg).unwrap();
1344        assert!(json.contains("\"role\":\"user\""));
1345        assert!(json.contains("\"content\":\"hello\""));
1346    }
1347
1348    #[test]
1349    fn message_part_serde_round_trip() {
1350        let parts = vec![
1351            MessagePart::Text {
1352                text: "hello".into(),
1353            },
1354            MessagePart::ToolOutput {
1355                tool_name: "bash".into(),
1356                body: "output".into(),
1357                compacted_at: None,
1358            },
1359            MessagePart::Recall {
1360                text: "recall".into(),
1361            },
1362            MessagePart::CodeContext {
1363                text: "code".into(),
1364            },
1365            MessagePart::Summary {
1366                text: "summary".into(),
1367            },
1368        ];
1369        let json = serde_json::to_string(&parts).unwrap();
1370        let deserialized: Vec<MessagePart> = serde_json::from_str(&json).unwrap();
1371        assert_eq!(deserialized.len(), 5);
1372    }
1373
1374    #[test]
1375    fn from_legacy_creates_empty_parts() {
1376        let msg = Message::from_legacy(Role::User, "hello");
1377        assert_eq!(msg.role, Role::User);
1378        assert_eq!(msg.content, "hello");
1379        assert!(msg.parts.is_empty());
1380        assert_eq!(msg.to_llm_content(), "hello");
1381    }
1382
1383    #[test]
1384    fn from_parts_flattens_content() {
1385        let msg = Message::from_parts(
1386            Role::System,
1387            vec![MessagePart::Recall {
1388                text: "recalled data".into(),
1389            }],
1390        );
1391        assert_eq!(msg.content, "recalled data");
1392        assert_eq!(msg.to_llm_content(), "recalled data");
1393        assert_eq!(msg.parts.len(), 1);
1394    }
1395
1396    #[test]
1397    fn from_parts_tool_output_format() {
1398        let msg = Message::from_parts(
1399            Role::User,
1400            vec![MessagePart::ToolOutput {
1401                tool_name: "bash".into(),
1402                body: "hello world".into(),
1403                compacted_at: None,
1404            }],
1405        );
1406        assert!(msg.content.contains("[tool output: bash]"));
1407        assert!(msg.content.contains("hello world"));
1408    }
1409
1410    #[test]
1411    fn message_deserializes_without_parts() {
1412        let json = r#"{"role":"user","content":"hello"}"#;
1413        let msg: Message = serde_json::from_str(json).unwrap();
1414        assert_eq!(msg.content, "hello");
1415        assert!(msg.parts.is_empty());
1416    }
1417
1418    #[test]
1419    fn flatten_skips_compacted_tool_output_empty_body() {
1420        // When compacted_at is set and body is empty, renders "(pruned)".
1421        let msg = Message::from_parts(
1422            Role::User,
1423            vec![
1424                MessagePart::Text {
1425                    text: "prefix ".into(),
1426                },
1427                MessagePart::ToolOutput {
1428                    tool_name: "bash".into(),
1429                    body: String::new(),
1430                    compacted_at: Some(1234),
1431                },
1432                MessagePart::Text {
1433                    text: " suffix".into(),
1434                },
1435            ],
1436        );
1437        assert!(msg.content.contains("(pruned)"));
1438        assert!(msg.content.contains("prefix "));
1439        assert!(msg.content.contains(" suffix"));
1440    }
1441
1442    #[test]
1443    fn flatten_compacted_tool_output_with_reference_renders_body() {
1444        // When compacted_at is set and body contains a reference notice, renders the body.
1445        let ref_notice = "[tool output pruned; full content at /tmp/overflow/big.txt]";
1446        let msg = Message::from_parts(
1447            Role::User,
1448            vec![MessagePart::ToolOutput {
1449                tool_name: "bash".into(),
1450                body: ref_notice.into(),
1451                compacted_at: Some(1234),
1452            }],
1453        );
1454        assert!(msg.content.contains(ref_notice));
1455        assert!(!msg.content.contains("(pruned)"));
1456    }
1457
1458    #[test]
1459    fn rebuild_content_syncs_after_mutation() {
1460        let mut msg = Message::from_parts(
1461            Role::User,
1462            vec![MessagePart::ToolOutput {
1463                tool_name: "bash".into(),
1464                body: "original".into(),
1465                compacted_at: None,
1466            }],
1467        );
1468        assert!(msg.content.contains("original"));
1469
1470        if let MessagePart::ToolOutput {
1471            ref mut compacted_at,
1472            ref mut body,
1473            ..
1474        } = msg.parts[0]
1475        {
1476            *compacted_at = Some(999);
1477            body.clear(); // simulate pruning: body cleared, no overflow notice
1478        }
1479        msg.rebuild_content();
1480
1481        assert!(msg.content.contains("(pruned)"));
1482        assert!(!msg.content.contains("original"));
1483    }
1484
1485    #[test]
1486    fn message_part_tool_use_serde_round_trip() {
1487        let part = MessagePart::ToolUse {
1488            id: "toolu_123".into(),
1489            name: "bash".into(),
1490            input: serde_json::json!({"command": "ls"}),
1491        };
1492        let json = serde_json::to_string(&part).unwrap();
1493        let deserialized: MessagePart = serde_json::from_str(&json).unwrap();
1494        if let MessagePart::ToolUse { id, name, input } = deserialized {
1495            assert_eq!(id, "toolu_123");
1496            assert_eq!(name, "bash");
1497            assert_eq!(input["command"], "ls");
1498        } else {
1499            panic!("expected ToolUse");
1500        }
1501    }
1502
1503    #[test]
1504    fn message_part_tool_result_serde_round_trip() {
1505        let part = MessagePart::ToolResult {
1506            tool_use_id: "toolu_123".into(),
1507            content: "file1.rs\nfile2.rs".into(),
1508            is_error: false,
1509        };
1510        let json = serde_json::to_string(&part).unwrap();
1511        let deserialized: MessagePart = serde_json::from_str(&json).unwrap();
1512        if let MessagePart::ToolResult {
1513            tool_use_id,
1514            content,
1515            is_error,
1516        } = deserialized
1517        {
1518            assert_eq!(tool_use_id, "toolu_123");
1519            assert_eq!(content, "file1.rs\nfile2.rs");
1520            assert!(!is_error);
1521        } else {
1522            panic!("expected ToolResult");
1523        }
1524    }
1525
1526    #[test]
1527    fn message_part_tool_result_is_error_default() {
1528        let json = r#"{"kind":"tool_result","tool_use_id":"id","content":"err"}"#;
1529        let part: MessagePart = serde_json::from_str(json).unwrap();
1530        if let MessagePart::ToolResult { is_error, .. } = part {
1531            assert!(!is_error);
1532        } else {
1533            panic!("expected ToolResult");
1534        }
1535    }
1536
1537    #[test]
1538    fn chat_response_construction() {
1539        let text = ChatResponse::Text("hello".into());
1540        assert_matches!(text, ChatResponse::Text(s) if s == "hello");
1541
1542        let tool_use = ChatResponse::ToolUse {
1543            text: Some("I'll run that".into()),
1544            tool_calls: vec![ToolUseRequest {
1545                id: "1".into(),
1546                name: "bash".into(),
1547                input: serde_json::json!({}),
1548            }],
1549            thinking_blocks: vec![],
1550        };
1551        assert_matches!(tool_use, ChatResponse::ToolUse { .. });
1552    }
1553
1554    #[test]
1555    fn flatten_parts_tool_use() {
1556        let msg = Message::from_parts(
1557            Role::Assistant,
1558            vec![MessagePart::ToolUse {
1559                id: "t1".into(),
1560                name: "bash".into(),
1561                input: serde_json::json!({"command": "ls"}),
1562            }],
1563        );
1564        assert!(msg.content.contains("[tool_use: bash(t1)]"));
1565    }
1566
1567    #[test]
1568    fn flatten_parts_tool_result() {
1569        let msg = Message::from_parts(
1570            Role::User,
1571            vec![MessagePart::ToolResult {
1572                tool_use_id: "t1".into(),
1573                content: "output here".into(),
1574                is_error: false,
1575            }],
1576        );
1577        assert!(msg.content.contains("[tool_result: t1]"));
1578        assert!(msg.content.contains("output here"));
1579    }
1580
1581    #[test]
1582    fn tool_definition_serde_round_trip() {
1583        let def = ToolDefinition {
1584            name: "bash".into(),
1585            description: "Execute a shell command".into(),
1586            parameters: serde_json::json!({"type": "object"}),
1587            output_schema: None,
1588        };
1589        let json = serde_json::to_string(&def).unwrap();
1590        let deserialized: ToolDefinition = serde_json::from_str(&json).unwrap();
1591        assert_eq!(deserialized.name, "bash");
1592        assert_eq!(deserialized.description, "Execute a shell command");
1593    }
1594
1595    #[tokio::test]
1596    async fn chat_with_tools_default_delegates_to_chat() {
1597        let provider = StubProvider {
1598            response: "hello".into(),
1599        };
1600        let messages = vec![Message::from_legacy(Role::User, "test")];
1601        let result = provider.chat_with_tools(&messages, &[]).await.unwrap();
1602        assert_matches!(result, ChatResponse::Text(s) if s == "hello");
1603    }
1604
1605    #[test]
1606    fn tool_output_compacted_at_serde_default() {
1607        let json = r#"{"kind":"tool_output","tool_name":"bash","body":"out"}"#;
1608        let part: MessagePart = serde_json::from_str(json).unwrap();
1609        if let MessagePart::ToolOutput { compacted_at, .. } = part {
1610            assert!(compacted_at.is_none());
1611        } else {
1612            panic!("expected ToolOutput");
1613        }
1614    }
1615
1616    // --- M27: strip_json_fences tests ---
1617
1618    #[test]
1619    fn strip_json_fences_plain_json() {
1620        assert_eq!(strip_json_fences(r#"{"a": 1}"#), r#"{"a": 1}"#);
1621    }
1622
1623    #[test]
1624    fn strip_json_fences_with_json_fence() {
1625        assert_eq!(strip_json_fences("```json\n{\"a\": 1}\n```"), r#"{"a": 1}"#);
1626    }
1627
1628    #[test]
1629    fn strip_json_fences_with_plain_fence() {
1630        assert_eq!(strip_json_fences("```\n{\"a\": 1}\n```"), r#"{"a": 1}"#);
1631    }
1632
1633    #[test]
1634    fn strip_json_fences_whitespace() {
1635        assert_eq!(strip_json_fences("  \n  "), "");
1636    }
1637
1638    #[test]
1639    fn strip_json_fences_empty() {
1640        assert_eq!(strip_json_fences(""), "");
1641    }
1642
1643    #[test]
1644    fn strip_json_fences_outer_whitespace() {
1645        assert_eq!(
1646            strip_json_fences("  ```json\n{\"a\": 1}\n```  "),
1647            r#"{"a": 1}"#
1648        );
1649    }
1650
1651    #[test]
1652    fn strip_json_fences_only_opening_fence() {
1653        assert_eq!(strip_json_fences("```json\n{\"a\": 1}"), r#"{"a": 1}"#);
1654    }
1655
1656    // --- M27: chat_typed tests ---
1657
1658    #[derive(Debug, serde::Deserialize, schemars::JsonSchema, PartialEq)]
1659    struct TestOutput {
1660        value: String,
1661    }
1662
1663    struct SequentialStub {
1664        responses: std::sync::Mutex<Vec<Result<String, LlmError>>>,
1665    }
1666
1667    impl SequentialStub {
1668        fn new(responses: Vec<Result<String, LlmError>>) -> Self {
1669            Self {
1670                responses: std::sync::Mutex::new(responses),
1671            }
1672        }
1673    }
1674
1675    impl LlmProvider for SequentialStub {
1676        async fn chat(&self, _messages: &[Message]) -> Result<String, LlmError> {
1677            let mut responses = self.responses.lock().unwrap();
1678            if responses.is_empty() {
1679                return Err(LlmError::Other("no more responses".into()));
1680            }
1681            responses.remove(0)
1682        }
1683
1684        async fn chat_stream(&self, messages: &[Message]) -> Result<ChatStream, LlmError> {
1685            let response = self.chat(messages).await?;
1686            Ok(Box::pin(tokio_stream::once(Ok(StreamChunk::Content(
1687                response,
1688            )))))
1689        }
1690
1691        fn supports_streaming(&self) -> bool {
1692            false
1693        }
1694
1695        async fn embed(&self, _text: &str) -> Result<Vec<f32>, LlmError> {
1696            Err(LlmError::EmbedUnsupported {
1697                provider: "sequential-stub".into(),
1698            })
1699        }
1700
1701        fn supports_embeddings(&self) -> bool {
1702            false
1703        }
1704
1705        fn name(&self) -> &'static str {
1706            "sequential-stub"
1707        }
1708    }
1709
1710    #[tokio::test]
1711    async fn chat_typed_happy_path() {
1712        let provider = StubProvider {
1713            response: r#"{"value": "hello"}"#.into(),
1714        };
1715        let messages = vec![Message::from_legacy(Role::User, "test")];
1716        let result: TestOutput = provider.chat_typed(&messages).await.unwrap();
1717        assert_eq!(
1718            result,
1719            TestOutput {
1720                value: "hello".into()
1721            }
1722        );
1723    }
1724
1725    #[tokio::test]
1726    async fn chat_typed_retry_succeeds() {
1727        let provider = SequentialStub::new(vec![
1728            Ok("not valid json".into()),
1729            Ok(r#"{"value": "ok"}"#.into()),
1730        ]);
1731        let messages = vec![Message::from_legacy(Role::User, "test")];
1732        let result: TestOutput = provider.chat_typed(&messages).await.unwrap();
1733        assert_eq!(result, TestOutput { value: "ok".into() });
1734    }
1735
1736    #[tokio::test]
1737    async fn chat_typed_both_fail() {
1738        let provider = SequentialStub::new(vec![Ok("bad json".into()), Ok("still bad".into())]);
1739        let messages = vec![Message::from_legacy(Role::User, "test")];
1740        let result = provider.chat_typed::<TestOutput>(&messages).await;
1741        let err = result.unwrap_err();
1742        assert!(err.to_string().contains("parse failed after retry"));
1743    }
1744
1745    #[tokio::test]
1746    async fn chat_typed_chat_error_propagates() {
1747        let provider = SequentialStub::new(vec![Err(LlmError::Unavailable)]);
1748        let messages = vec![Message::from_legacy(Role::User, "test")];
1749        let result = provider.chat_typed::<TestOutput>(&messages).await;
1750        assert_matches!(result, Err(LlmError::Unavailable));
1751    }
1752
1753    #[tokio::test]
1754    async fn chat_typed_strips_fences() {
1755        let provider = StubProvider {
1756            response: "```json\n{\"value\": \"fenced\"}\n```".into(),
1757        };
1758        let messages = vec![Message::from_legacy(Role::User, "test")];
1759        let result: TestOutput = provider.chat_typed(&messages).await.unwrap();
1760        assert_eq!(
1761            result,
1762            TestOutput {
1763                value: "fenced".into()
1764            }
1765        );
1766    }
1767
1768    #[test]
1769    fn supports_structured_output_default_false() {
1770        let provider = StubProvider {
1771            response: String::new(),
1772        };
1773        assert!(!provider.supports_structured_output());
1774    }
1775
1776    #[test]
1777    fn structured_parse_error_display() {
1778        let err = LlmError::StructuredParse("test error".into());
1779        assert_eq!(
1780            err.to_string(),
1781            "structured output parse failed: test error"
1782        );
1783    }
1784
1785    #[test]
1786    fn message_part_image_roundtrip_json() {
1787        let part = MessagePart::Image(Box::new(ImageData {
1788            data: vec![1, 2, 3, 4],
1789            mime_type: "image/jpeg".into(),
1790        }));
1791        let json = serde_json::to_string(&part).unwrap();
1792        let decoded: MessagePart = serde_json::from_str(&json).unwrap();
1793        match decoded {
1794            MessagePart::Image(img) => {
1795                assert_eq!(img.data, vec![1, 2, 3, 4]);
1796                assert_eq!(img.mime_type, "image/jpeg");
1797            }
1798            _ => panic!("expected Image variant"),
1799        }
1800    }
1801
1802    #[test]
1803    fn flatten_parts_includes_image_placeholder() {
1804        let msg = Message::from_parts(
1805            Role::User,
1806            vec![
1807                MessagePart::Text {
1808                    text: "see this".into(),
1809                },
1810                MessagePart::Image(Box::new(ImageData {
1811                    data: vec![0u8; 100],
1812                    mime_type: "image/png".into(),
1813                })),
1814            ],
1815        );
1816        let content = msg.to_llm_content();
1817        assert!(content.contains("see this"));
1818        assert!(content.contains("[image: image/png"));
1819    }
1820
1821    #[test]
1822    fn supports_vision_default_false() {
1823        let provider = StubProvider {
1824            response: String::new(),
1825        };
1826        assert!(!provider.supports_vision());
1827    }
1828
1829    #[test]
1830    fn message_metadata_default_both_visible() {
1831        let m = MessageMetadata::default();
1832        assert!(m.visibility.is_agent_visible());
1833        assert!(m.visibility.is_user_visible());
1834        assert_eq!(m.visibility, MessageVisibility::Both);
1835        assert!(m.compacted_at.is_none());
1836    }
1837
1838    #[test]
1839    fn message_metadata_agent_only() {
1840        let m = MessageMetadata::agent_only();
1841        assert!(m.visibility.is_agent_visible());
1842        assert!(!m.visibility.is_user_visible());
1843        assert_eq!(m.visibility, MessageVisibility::AgentOnly);
1844    }
1845
1846    #[test]
1847    fn message_metadata_user_only() {
1848        let m = MessageMetadata::user_only();
1849        assert!(!m.visibility.is_agent_visible());
1850        assert!(m.visibility.is_user_visible());
1851        assert_eq!(m.visibility, MessageVisibility::UserOnly);
1852    }
1853
1854    #[test]
1855    fn message_metadata_serde_default() {
1856        let json = r#"{"role":"user","content":"hello"}"#;
1857        let msg: Message = serde_json::from_str(json).unwrap();
1858        assert!(msg.metadata.visibility.is_agent_visible());
1859        assert!(msg.metadata.visibility.is_user_visible());
1860    }
1861
1862    #[test]
1863    fn message_metadata_round_trip() {
1864        let msg = Message {
1865            role: Role::User,
1866            content: "test".into(),
1867            parts: vec![],
1868            metadata: MessageMetadata::agent_only(),
1869        };
1870        let json = serde_json::to_string(&msg).unwrap();
1871        let decoded: Message = serde_json::from_str(&json).unwrap();
1872        assert!(decoded.metadata.visibility.is_agent_visible());
1873        assert!(!decoded.metadata.visibility.is_user_visible());
1874        assert_eq!(decoded.metadata.visibility, MessageVisibility::AgentOnly);
1875    }
1876
1877    #[test]
1878    fn message_part_compaction_round_trip() {
1879        let part = MessagePart::Compaction {
1880            summary: "Context was summarized.".to_owned(),
1881        };
1882        let json = serde_json::to_string(&part).unwrap();
1883        let decoded: MessagePart = serde_json::from_str(&json).unwrap();
1884        assert!(
1885            matches!(decoded, MessagePart::Compaction { summary } if summary == "Context was summarized.")
1886        );
1887    }
1888
1889    #[test]
1890    fn flatten_parts_compaction_contributes_no_text() {
1891        // MessagePart::Compaction must not appear in the flattened content string
1892        // (it's metadata-only; the summary is stored on the Message separately).
1893        let parts = vec![
1894            MessagePart::Text {
1895                text: "Hello".to_owned(),
1896            },
1897            MessagePart::Compaction {
1898                summary: "Summary".to_owned(),
1899            },
1900        ];
1901        let msg = Message::from_parts(Role::Assistant, parts);
1902        // Only the Text part should appear in content.
1903        assert_eq!(msg.content.trim(), "Hello");
1904    }
1905
1906    #[test]
1907    fn stream_chunk_compaction_variant() {
1908        let chunk = StreamChunk::Compaction("A summary".to_owned());
1909        assert_matches!(chunk, StreamChunk::Compaction(s) if s == "A summary");
1910    }
1911
1912    #[test]
1913    fn short_type_name_extracts_last_segment() {
1914        struct MyOutput;
1915        assert_eq!(short_type_name::<MyOutput>(), "MyOutput");
1916    }
1917
1918    #[test]
1919    fn short_type_name_primitive_returns_full_name() {
1920        // Primitives have no "::" in their type_name — rsplit returns the full name.
1921        assert_eq!(short_type_name::<u32>(), "u32");
1922        assert_eq!(short_type_name::<bool>(), "bool");
1923    }
1924
1925    #[test]
1926    fn short_type_name_nested_path_returns_last() {
1927        // Use a type whose path contains "::" segments.
1928        assert_eq!(
1929            short_type_name::<std::collections::HashMap<u32, u32>>(),
1930            "HashMap<u32, u32>"
1931        );
1932    }
1933
1934    // Regression test for #2257: `MessagePart::Summary` must serialize to the
1935    // internally-tagged format `{"kind":"summary","text":"..."}` and round-trip correctly.
1936    #[test]
1937    fn summary_roundtrip() {
1938        let part = MessagePart::Summary {
1939            text: "hello".to_string(),
1940        };
1941        let json = serde_json::to_string(&part).expect("serialization must not fail");
1942        assert!(
1943            json.contains("\"kind\":\"summary\""),
1944            "must use internally-tagged format, got: {json}"
1945        );
1946        assert!(
1947            !json.contains("\"Summary\""),
1948            "must not use externally-tagged format, got: {json}"
1949        );
1950        let decoded: MessagePart =
1951            serde_json::from_str(&json).expect("deserialization must not fail");
1952        match decoded {
1953            MessagePart::Summary { text } => assert_eq!(text, "hello"),
1954            other => panic!("expected MessagePart::Summary, got {other:?}"),
1955        }
1956    }
1957
1958    #[tokio::test]
1959    async fn embed_batch_default_empty_returns_empty() {
1960        let provider = StubProvider {
1961            response: String::new(),
1962        };
1963        let result = provider.embed_batch(&[]).await.unwrap();
1964        assert!(result.is_empty());
1965    }
1966
1967    #[tokio::test]
1968    async fn embed_batch_default_calls_embed_sequentially() {
1969        let provider = StubProvider {
1970            response: String::new(),
1971        };
1972        let texts = ["hello", "world", "foo"];
1973        let result = provider.embed_batch(&texts).await.unwrap();
1974        assert_eq!(result.len(), 3);
1975        // StubProvider::embed always returns [0.1, 0.2, 0.3]
1976        for vec in &result {
1977            assert_eq!(vec, &[0.1_f32, 0.2, 0.3]);
1978        }
1979    }
1980
1981    #[test]
1982    fn message_visibility_db_roundtrip_both() {
1983        assert_eq!(MessageVisibility::Both.as_db_str(), "both");
1984        assert_eq!(
1985            MessageVisibility::from_db_str("both"),
1986            MessageVisibility::Both
1987        );
1988    }
1989
1990    #[test]
1991    fn message_visibility_db_roundtrip_agent_only() {
1992        assert_eq!(MessageVisibility::AgentOnly.as_db_str(), "agent_only");
1993        assert_eq!(
1994            MessageVisibility::from_db_str("agent_only"),
1995            MessageVisibility::AgentOnly
1996        );
1997    }
1998
1999    #[test]
2000    fn message_visibility_db_roundtrip_user_only() {
2001        assert_eq!(MessageVisibility::UserOnly.as_db_str(), "user_only");
2002        assert_eq!(
2003            MessageVisibility::from_db_str("user_only"),
2004            MessageVisibility::UserOnly
2005        );
2006    }
2007
2008    #[test]
2009    fn message_visibility_from_db_str_unknown_defaults_to_both() {
2010        assert_eq!(
2011            MessageVisibility::from_db_str("unknown_future_value"),
2012            MessageVisibility::Both
2013        );
2014        assert_eq!(MessageVisibility::from_db_str(""), MessageVisibility::Both);
2015    }
2016}