Skip to main content

modular_agent_core/
llm.rs

1//! LLM message types for agent-based workflows.
2//!
3//! This module provides types for representing chat messages in LLM conversations,
4//! including support for tool calls, streaming responses, and multimodal content.
5
6#![cfg(feature = "llm")]
7
8use std::{sync::Arc, vec};
9
10use im::Vector;
11use serde::{Deserialize, Serialize};
12
13use crate::error::AgentError;
14use crate::value::AgentValue;
15
16#[cfg(feature = "image")]
17use photon_rs::PhotonImage;
18
19/// One block of structured [`Message`] content.
20///
21/// Serialized as an internally tagged object (`{"type": "text", ...}`) so
22/// block arrays in preset JSON stay self-describing.
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24#[serde(tag = "type", rename_all = "snake_case")]
25pub enum ContentBlock {
26    /// Plain text content.
27    Text {
28        /// The text.
29        text: String,
30    },
31
32    /// Reasoning/thinking trace from an extended thinking model.
33    Thinking {
34        /// The thinking text. For redacted blocks this holds the provider's
35        /// opaque encrypted payload verbatim.
36        thinking: String,
37
38        /// Provider signature that must be replayed together with the
39        /// thinking text when re-sending assistant history (Claude requires
40        /// it for extended thinking + tool use continuations).
41        #[serde(skip_serializing_if = "Option::is_none", default)]
42        signature: Option<String>,
43
44        /// True when the provider returned an encrypted `redacted_thinking`
45        /// payload instead of readable text.
46        #[serde(skip_serializing_if = "std::ops::Not::not", default)]
47        redacted: bool,
48    },
49
50    /// Inline image content (requires "image" feature).
51    #[cfg(feature = "image")]
52    Image {
53        /// Base64-encoded image data.
54        data: String,
55
56        /// MIME type of the image, e.g. "image/png".
57        mime_type: String,
58    },
59}
60
61/// Content of a [`Message`]: plain text or a sequence of [`ContentBlock`]s.
62///
63/// Plain text serializes as a JSON string — the pre-block format — so
64/// text-only histories written by this version can still be read by older
65/// versions. Block content serializes as a tagged array and is only
66/// produced when a message actually carries thinking or image blocks.
67#[derive(Debug, Clone, PartialEq)]
68pub enum MessageContent {
69    /// Plain text content (the common case).
70    Text(String),
71
72    /// Structured content preserving provider block order.
73    Blocks(Vec<ContentBlock>),
74}
75
76impl Default for MessageContent {
77    fn default() -> Self {
78        MessageContent::Text(String::new())
79    }
80}
81
82impl From<String> for MessageContent {
83    fn from(s: String) -> Self {
84        MessageContent::Text(s)
85    }
86}
87
88impl From<&str> for MessageContent {
89    fn from(s: &str) -> Self {
90        MessageContent::Text(s.to_string())
91    }
92}
93
94impl MessageContent {
95    /// Concatenated text of all text content.
96    pub fn text(&self) -> String {
97        match self {
98            MessageContent::Text(s) => s.clone(),
99            MessageContent::Blocks(blocks) => blocks
100                .iter()
101                .filter_map(|b| match b {
102                    ContentBlock::Text { text } => Some(text.as_str()),
103                    _ => None,
104                })
105                .collect(),
106        }
107    }
108}
109
110/// Absorbs the legacy top-level `thinking` string field into a leading
111/// Thinking block. Providers emit thinking before the answer text, so the
112/// absorbed block leads.
113fn absorb_legacy_thinking(content: MessageContent, thinking: String) -> MessageContent {
114    let mut blocks = match content {
115        MessageContent::Text(s) if s.is_empty() => vec![],
116        MessageContent::Text(s) => vec![ContentBlock::Text { text: s }],
117        MessageContent::Blocks(blocks) => blocks,
118    };
119    blocks.insert(
120        0,
121        ContentBlock::Thinking {
122            thinking,
123            signature: None,
124            redacted: false,
125        },
126    );
127    MessageContent::Blocks(blocks)
128}
129
130/// A chat message in an LLM conversation.
131///
132/// Represents messages exchanged between users, assistants, and tools in a conversation.
133/// Supports various roles (user, assistant, system, tool) and optional features like
134/// streaming, thinking traces, and attached images.
135///
136/// # Fields
137///
138/// * `id` - Optional unique identifier for the message
139/// * `role` - The role of the message sender ("user", "assistant", "system", "tool")
140/// * `content` - The content of the message: plain text or structured blocks
141/// * `tokens` - Optional token count for the message
142/// * `streaming` - Whether this is a partial streaming response
143/// * `tool_calls` - Tool invocations requested by the assistant
144/// * `tool_name` - Name of the tool (for tool role messages)
145/// * `is_error` - Marks a tool-result message as an error
146/// * `stop_reason` - Normalized reason the LLM stopped generating
147/// * `usage` - Token usage reported by the provider (final messages only)
148/// * `image` - Optional attached image (requires "image" feature)
149///
150/// # Example
151///
152/// ```
153/// use modular_agent_core::Message;
154///
155/// let user_msg = Message::user("What is the weather?".to_string());
156/// let assistant_msg = Message::assistant("The weather is sunny.".to_string());
157/// let system_msg = Message::system("You are a helpful assistant.".to_string());
158/// ```
159#[derive(Debug, Default, Clone)]
160pub struct Message {
161    /// Unique identifier for this message.
162    pub id: Option<String>,
163
164    /// Role of the message sender: "user", "assistant", "system", or "tool".
165    pub role: String,
166
167    /// Content of the message: plain text or structured blocks. Use
168    /// [`Message::text`] for the concatenated text and [`Message::thinking`]
169    /// for the thinking trace.
170    pub content: MessageContent,
171
172    /// Token count for this message (if available).
173    pub tokens: Option<usize>,
174
175    /// Whether this is a partial streaming response.
176    pub streaming: bool,
177
178    /// Tool calls requested by the assistant in this message.
179    pub tool_calls: Option<Vector<ToolCall>>,
180
181    /// Name of the tool (for tool role messages containing tool results).
182    pub tool_name: Option<String>,
183
184    /// Marks a tool-result message as an error, per Claude's `tool_result` `is_error`.
185    pub is_error: Option<bool>,
186
187    /// Normalized reason the LLM stopped generating this message:
188    /// "stop" | "tool_use" | "length" | "error" | "aborted". Unknown
189    /// provider values are passed through unchanged.
190    pub stop_reason: Option<String>,
191
192    /// Token usage reported by the provider. Only set on final assistant
193    /// messages (`streaming == false`); partial streaming emissions never
194    /// carry usage.
195    pub usage: Option<Usage>,
196
197    /// Attached image for multimodal messages (requires "image" feature).
198    #[cfg(feature = "image")]
199    pub image: Option<Arc<PhotonImage>>,
200}
201
202impl Message {
203    /// Creates a new message with the specified role and content.
204    ///
205    /// # Arguments
206    ///
207    /// * `role` - The role of the message sender
208    /// * `content` - The text content of the message
209    pub fn new(role: String, content: String) -> Self {
210        Self {
211            id: None,
212            role,
213            content: MessageContent::Text(content),
214            tokens: None,
215            streaming: false,
216            tool_calls: None,
217            tool_name: None,
218            is_error: None,
219            stop_reason: None,
220            usage: None,
221
222            #[cfg(feature = "image")]
223            image: None,
224        }
225    }
226
227    /// Creates an assistant message with the given content.
228    pub fn assistant(content: String) -> Self {
229        Message::new("assistant".to_string(), content)
230    }
231
232    /// Creates a system message with the given content.
233    ///
234    /// System messages typically set the behavior or context for the assistant.
235    pub fn system(content: String) -> Self {
236        Message::new("system".to_string(), content)
237    }
238
239    /// Creates a user message with the given content.
240    pub fn user(content: String) -> Self {
241        Message::new("user".to_string(), content)
242    }
243
244    /// Creates a tool response message.
245    ///
246    /// Tool messages contain the result of a tool call and are associated
247    /// with a specific tool by name.
248    ///
249    /// # Arguments
250    ///
251    /// * `tool_name` - The name of the tool that produced this result
252    /// * `content` - The tool's output/result as a string
253    pub fn tool(tool_name: String, content: String) -> Self {
254        let mut message = Message::new("tool".to_string(), content);
255        message.tool_name = Some(tool_name);
256        message
257    }
258
259    /// Creates a tool response message with structured content.
260    ///
261    /// Like [`Message::tool`], but accepts [`MessageContent`] directly so a
262    /// tool result can carry content blocks (e.g. images) instead of plain
263    /// text.
264    ///
265    /// # Arguments
266    ///
267    /// * `tool_name` - The name of the tool that produced this result
268    /// * `content` - The tool's output/result as message content
269    pub fn tool_with_content(tool_name: String, content: MessageContent) -> Self {
270        let mut message = Message::new("tool".to_string(), String::new());
271        message.content = content;
272        message.tool_name = Some(tool_name);
273        message
274    }
275
276    /// Attaches an image to this message (builder pattern).
277    ///
278    /// Only available when the "image" feature is enabled.
279    #[cfg(feature = "image")]
280    pub fn with_image(mut self, image: Arc<PhotonImage>) -> Self {
281        self.image = Some(image);
282        self
283    }
284
285    /// Concatenated text of all text content — the common read path.
286    pub fn text(&self) -> String {
287        self.content.text()
288    }
289
290    /// Concatenated thinking text, or `None` when the message has no
291    /// thinking blocks. Replaces the former `thinking` field: redacted
292    /// blocks surface as a `"[redacted]"` placeholder (their `thinking`
293    /// holds an opaque encrypted payload meant only for provider replay)
294    /// and multiple blocks are joined with a newline, preserving the old
295    /// field's observable form.
296    pub fn thinking(&self) -> Option<String> {
297        let MessageContent::Blocks(blocks) = &self.content else {
298            return None;
299        };
300        let parts: Vec<&str> = blocks
301            .iter()
302            .filter_map(|b| match b {
303                ContentBlock::Thinking { redacted: true, .. } => Some("[redacted]"),
304                ContentBlock::Thinking { thinking, .. } => Some(thinking.as_str()),
305                _ => None,
306            })
307            .collect();
308        if parts.is_empty() {
309            None
310        } else {
311            Some(parts.join("\n"))
312        }
313    }
314}
315
316impl PartialEq for Message {
317    fn eq(&self, other: &Self) -> bool {
318        self.id == other.id && self.role == other.role && self.content == other.content
319    }
320}
321
322impl Serialize for Message {
323    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
324    where
325        S: serde::Serializer,
326    {
327        let mut map = serde_json::Map::new();
328        if let Some(id) = &self.id {
329            map.insert("id".to_string(), serde_json::Value::String(id.clone()));
330        }
331        map.insert(
332            "role".to_string(),
333            serde_json::Value::String(self.role.clone()),
334        );
335        // Text-only content keeps the legacy string form so histories that
336        // never used thinking or image blocks stay readable by older
337        // versions. Only block content that cannot be flattened to plain
338        // text is written as an array.
339        let content_value = match &self.content {
340            MessageContent::Text(s) => serde_json::Value::String(s.clone()),
341            MessageContent::Blocks(blocks)
342                if blocks
343                    .iter()
344                    .all(|b| matches!(b, ContentBlock::Text { .. })) =>
345            {
346                serde_json::Value::String(self.content.text())
347            }
348            MessageContent::Blocks(blocks) => {
349                serde_json::to_value(blocks).map_err(serde::ser::Error::custom)?
350            }
351        };
352        map.insert("content".to_string(), content_value);
353        if let Some(tokens) = &self.tokens {
354            map.insert(
355                "tokens".to_string(),
356                serde_json::Value::Number((*tokens).into()),
357            );
358        }
359        if self.streaming {
360            map.insert("streaming".to_string(), serde_json::Value::Bool(true));
361        }
362        if let Some(tool_calls) = &self.tool_calls {
363            let mut tool_calls_vec = vec![];
364            for call in tool_calls {
365                tool_calls_vec.push(serde_json::to_value(call).map_err(serde::ser::Error::custom)?);
366            }
367            map.insert(
368                "tool_calls".to_string(),
369                serde_json::Value::Array(tool_calls_vec),
370            );
371        }
372        if let Some(tool_name) = &self.tool_name {
373            map.insert(
374                "tool_name".to_string(),
375                serde_json::Value::String(tool_name.clone()),
376            );
377        }
378        // Only emitted when set, so presets saved before this field existed
379        // round-trip unchanged.
380        if let Some(is_error) = &self.is_error {
381            map.insert("is_error".to_string(), serde_json::Value::Bool(*is_error));
382        }
383        if let Some(stop_reason) = &self.stop_reason {
384            map.insert(
385                "stop_reason".to_string(),
386                serde_json::Value::String(stop_reason.clone()),
387            );
388        }
389        if let Some(usage) = &self.usage {
390            map.insert(
391                "usage".to_string(),
392                serde_json::to_value(usage).map_err(serde::ser::Error::custom)?,
393            );
394        }
395        #[cfg(feature = "image")]
396        {
397            if let Some(image) = &self.image {
398                map.insert(
399                    "image".to_string(),
400                    serde_json::Value::String(image.get_base64()),
401                );
402            }
403        }
404        map.serialize(serializer)
405    }
406}
407
408impl<'de> Deserialize<'de> for Message {
409    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
410    where
411        D: serde::Deserializer<'de>,
412    {
413        let mut message = Message::user(String::default());
414        let map = serde_json::Map::deserialize(deserializer)?;
415
416        if let Some(id) = map.get("id") {
417            message.id = id.as_str().map(|s| s.to_string());
418        }
419        if let Some(role) = map.get("role") {
420            message.role = role
421                .as_str()
422                .ok_or_else(|| serde::de::Error::custom("role must be a string"))?
423                .to_string();
424        }
425        if let Some(content) = map.get("content") {
426            message.content = match content {
427                serde_json::Value::String(s) => MessageContent::Text(s.clone()),
428                serde_json::Value::Array(_) => {
429                    let blocks: Vec<ContentBlock> = serde_json::from_value(content.clone())
430                        .map_err(|e| {
431                            serde::de::Error::custom(format!("invalid content blocks: {e}"))
432                        })?;
433                    MessageContent::Blocks(blocks)
434                }
435                _ => {
436                    return Err(serde::de::Error::custom(
437                        "content must be a string or an array of content blocks",
438                    ));
439                }
440            };
441        }
442        if let Some(tokens) = map.get("tokens") {
443            message.tokens = tokens.as_u64().map(|u| u as usize);
444        }
445        // Legacy top-level "thinking" field (pre content-block format) is
446        // absorbed as a leading Thinking block.
447        if let Some(thinking) = map.get("thinking").and_then(|v| v.as_str()) {
448            message.content =
449                absorb_legacy_thinking(std::mem::take(&mut message.content), thinking.to_string());
450        }
451        if let Some(streaming) = map.get("streaming") {
452            message.streaming = streaming.as_bool().unwrap_or(false);
453        }
454        if let Some(tool_calls) = map.get("tool_calls") {
455            let tool_calls = serde_json::from_value::<Vec<ToolCall>>(tool_calls.clone())
456                .map_err(|e| serde::de::Error::custom(e.to_string()))?;
457            message.tool_calls = Some(tool_calls.into());
458        }
459        if let Some(tool_name) = map.get("tool_name") {
460            message.tool_name = tool_name.as_str().map(|s| s.to_string());
461        }
462        message.is_error = map.get("is_error").and_then(|v| v.as_bool());
463        message.stop_reason = map
464            .get("stop_reason")
465            .and_then(|v| v.as_str())
466            .map(|s| s.to_string());
467        // Lenient: an unparseable usage value becomes None rather than
468        // failing the whole message.
469        message.usage = map
470            .get("usage")
471            .and_then(|v| serde_json::from_value(v.clone()).ok());
472        #[cfg(feature = "image")]
473        if let Some(image) = map.get("image") {
474            let image_str = image
475                .as_str()
476                .ok_or_else(|| serde::de::Error::custom("image must be a string"))?;
477            let image = Arc::new(PhotonImage::new_from_base64(image_str));
478            message.image = Some(image);
479        }
480        Ok(message)
481    }
482}
483
484/// Token usage reported by an LLM provider for one assistant message.
485///
486/// `input_tokens` EXCLUDES cache_read/cache_write tokens (Anthropic-style
487/// accounting; OpenAI prompt_tokens are normalized by subtracting cached).
488#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
489pub struct Usage {
490    /// Non-cached input tokens billed for the request.
491    #[serde(default)]
492    pub input_tokens: u64,
493
494    /// Output tokens generated by the model.
495    #[serde(default)]
496    pub output_tokens: u64,
497
498    /// Input tokens read from the provider's prompt cache.
499    #[serde(default)]
500    pub cache_read_tokens: u64,
501
502    /// Input tokens written to the provider's prompt cache.
503    #[serde(default)]
504    pub cache_write_tokens: u64,
505}
506
507/// Flat token cost charged for each image, whether an inline content
508/// block or an attached `image` field. Roughly a 1024x1024 image on
509/// current providers; the exact cost varies by provider and resolution,
510/// so a single conservative constant keeps the estimate simple.
511#[cfg(feature = "image")]
512const IMAGE_TOKENS: u64 = 1200;
513
514/// Estimates the token count of a single [`Message`].
515///
516/// Uses the chars/4 heuristic: English text averages about four
517/// characters per token, so the total character count divided by four
518/// (rounded up) is a serviceable estimate without pulling in a
519/// tokenizer. Counted characters are:
520///
521/// - all text content,
522/// - all thinking content, including redacted payloads — they are
523///   replayed to the provider verbatim, so they occupy context,
524/// - for each tool call, the function name plus its serialized
525///   parameters.
526///
527/// Each image — an inline content block or the attached `image` field —
528/// adds a flat 1200 tokens (`IMAGE_TOKENS`) instead of a character
529/// count.
530pub fn estimate_message_tokens(m: &Message) -> u64 {
531    let mut chars: usize = 0;
532    #[cfg_attr(not(feature = "image"), allow(unused_mut))]
533    let mut image_tokens: u64 = 0;
534
535    match &m.content {
536        MessageContent::Text(s) => chars += s.len(),
537        MessageContent::Blocks(blocks) => {
538            for block in blocks {
539                match block {
540                    ContentBlock::Text { text } => chars += text.len(),
541                    ContentBlock::Thinking { thinking, .. } => chars += thinking.len(),
542
543                    #[cfg(feature = "image")]
544                    ContentBlock::Image { .. } => image_tokens += IMAGE_TOKENS,
545                }
546            }
547        }
548    }
549
550    if let Some(tool_calls) = &m.tool_calls {
551        for call in tool_calls {
552            chars += call.function.name.len();
553            chars += serde_json::to_string(&call.function.parameters).map_or(0, |s| s.len());
554        }
555    }
556
557    #[cfg(feature = "image")]
558    if m.image.is_some() {
559        image_tokens += IMAGE_TOKENS;
560    }
561
562    (chars as u64).div_ceil(4) + image_tokens
563}
564
565/// Estimates the total token count of a conversation context.
566///
567/// Hybrid estimation: provider-reported [`Usage`] is exact, so the
568/// latest assistant message carrying `usage` serves as an anchor. Its
569/// `input_tokens + output_tokens + cache_read_tokens +
570/// cache_write_tokens` already accounts for the entire context up to and
571/// including that message, so only messages after the anchor are
572/// estimated with [`estimate_message_tokens`]. When no message carries
573/// usage, every message is estimated.
574pub fn estimate_context_tokens(messages: &[Message]) -> u64 {
575    for (i, m) in messages.iter().enumerate().rev() {
576        if m.role == "assistant"
577            && let Some(usage) = &m.usage
578        {
579            let anchor = usage.input_tokens
580                + usage.output_tokens
581                + usage.cache_read_tokens
582                + usage.cache_write_tokens;
583            return anchor
584                + messages[i + 1..]
585                    .iter()
586                    .map(estimate_message_tokens)
587                    .sum::<u64>();
588        }
589    }
590    messages.iter().map(estimate_message_tokens).sum()
591}
592
593/// A tool call requested by the assistant.
594///
595/// Represents a single tool invocation as part of an LLM response.
596/// The assistant may request multiple tool calls in a single message.
597#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
598pub struct ToolCall {
599    /// The function to be called.
600    pub function: ToolCallFunction,
601}
602
603/// Details of a function call within a tool invocation.
604///
605/// Contains the function name, parameters, and optional call ID
606/// for correlating tool calls with their results.
607#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
608pub struct ToolCallFunction {
609    /// Name of the function/tool to invoke.
610    pub name: String,
611
612    /// Parameters to pass to the function as a JSON value.
613    pub parameters: serde_json::Value,
614
615    /// Optional unique identifier for this tool call (for correlation).
616    #[serde(skip_serializing_if = "Option::is_none")]
617    pub id: Option<String>,
618
619    /// Set when the provider-sent argument string could not be parsed as
620    /// JSON even after repair; call_tools turns this into an is_error
621    /// tool result instead of executing the call.
622    #[serde(skip_serializing_if = "Option::is_none", default)]
623    pub parse_error: Option<String>,
624}
625
626/// A typed streaming event describing the progress of one assistant [`Message`].
627///
628/// Providers emit these events while generating a response so downstream
629/// agents can distinguish incremental deltas from the final message instead
630/// of relying on repeated partial-`Message` re-sends. Each incremental event
631/// carries both the `delta` (just the new fragment) and the accumulated
632/// `partial` message, so consumers can either append deltas or replace the
633/// whole message — a single accumulation loop suffices.
634///
635/// Serialized as an internally tagged JSON object: the `type` field holds the
636/// snake_case variant name (e.g. `"text_delta"`, `"tool_call_end"`, `"done"`)
637/// and the variant fields are inlined alongside it.
638///
639/// # Variants
640///
641/// * `Start` - Generation began; `partial` is the (usually empty) initial message
642/// * `TextDelta` - New text content was appended to `partial`'s text content
643/// * `ThinkingDelta` - New thinking text was appended to `partial`'s thinking block
644/// * `ToolCallStart` - The assistant began emitting the tool call at `index`
645/// * `ToolCallDelta` - New argument text for the tool call at `index`
646/// * `ToolCallEnd` - The tool call at `index` is complete and parsed
647/// * `Done` - Generation finished; `message` is the final complete message
648/// * `Error` - Generation failed; `message` holds what was accumulated so far
649#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
650#[serde(tag = "type", rename_all = "snake_case")]
651pub enum MessageEvent {
652    /// Generation of a new assistant message has started.
653    Start {
654        /// The initial (typically empty) accumulated message.
655        partial: Message,
656    },
657
658    /// A fragment of text content was generated.
659    TextDelta {
660        /// The newly generated text fragment.
661        delta: String,
662        /// The accumulated message including this delta.
663        partial: Message,
664    },
665
666    /// A fragment of thinking/reasoning text was generated.
667    ThinkingDelta {
668        /// The newly generated thinking fragment.
669        delta: String,
670        /// The accumulated message including this delta.
671        partial: Message,
672    },
673
674    /// The assistant started emitting a tool call.
675    ToolCallStart {
676        /// Zero-based position of the tool call within the message.
677        index: usize,
678        /// The accumulated message so far.
679        partial: Message,
680    },
681
682    /// A fragment of tool-call arguments was generated.
683    ToolCallDelta {
684        /// Zero-based position of the tool call within the message.
685        index: usize,
686        /// The newly generated argument text fragment.
687        delta: String,
688        /// The accumulated message so far.
689        partial: Message,
690    },
691
692    /// A tool call is complete and its arguments have been parsed.
693    ToolCallEnd {
694        /// Zero-based position of the tool call within the message.
695        index: usize,
696        /// The completed tool call.
697        tool_call: ToolCall,
698        /// The accumulated message including this tool call.
699        partial: Message,
700    },
701
702    /// Generation finished successfully.
703    Done {
704        /// The final complete message.
705        message: Message,
706    },
707
708    /// Generation failed.
709    Error {
710        /// The message accumulated before the failure.
711        message: Message,
712        /// Description of the failure.
713        error: String,
714    },
715}
716
717impl TryFrom<MessageEvent> for AgentValue {
718    type Error = AgentError;
719
720    fn try_from(event: MessageEvent) -> Result<Self, AgentError> {
721        // Route through serde_json so the tagged representation on a port
722        // matches the serialized form exactly (including the "type" field).
723        let json = serde_json::to_value(&event).map_err(|e| {
724            AgentError::InvalidValue(format!("Failed to serialize MessageEvent: {e}"))
725        })?;
726        AgentValue::from_json(json)
727    }
728}
729
730impl TryFrom<AgentValue> for Message {
731    type Error = AgentError;
732
733    fn try_from(value: AgentValue) -> Result<Self, Self::Error> {
734        match value {
735            AgentValue::Message(msg) => Ok((*msg).clone()),
736            AgentValue::String(s) => Ok(Message::user(s.to_string())),
737
738            #[cfg(feature = "image")]
739            AgentValue::Image(img) => {
740                let mut message = Message::user("".to_string());
741                message.image = Some(img.clone());
742                Ok(message)
743            }
744            AgentValue::Object(obj) => {
745                let role = obj
746                    .get("role")
747                    .and_then(|r| r.as_str())
748                    .unwrap_or("user")
749                    .to_string();
750                let content_value = obj.get("content").ok_or_else(|| {
751                    AgentError::InvalidValue("Message object missing 'content' field".to_string())
752                })?;
753                let content = match content_value {
754                    AgentValue::String(s) => MessageContent::Text(s.to_string()),
755                    AgentValue::Array(_) => {
756                        let blocks: Vec<ContentBlock> =
757                            serde_json::from_value(content_value.to_json()).map_err(|e| {
758                                AgentError::InvalidValue(format!("Invalid content blocks: {e}"))
759                            })?;
760                        MessageContent::Blocks(blocks)
761                    }
762                    _ => {
763                        return Err(AgentError::InvalidValue(
764                            "'content' field must be a string or an array of content blocks"
765                                .to_string(),
766                        ));
767                    }
768                };
769                let mut message = Message::new(role, String::new());
770                message.content = content;
771
772                let id = obj
773                    .get("id")
774                    .and_then(|i| i.as_str())
775                    .map(|s| s.to_string());
776                message.id = id;
777
778                // Legacy top-level "thinking" field is absorbed as a leading
779                // Thinking block, mirroring the serde path.
780                if let Some(thinking) = obj.get("thinking").and_then(|t| t.as_str()) {
781                    message.content = absorb_legacy_thinking(
782                        std::mem::take(&mut message.content),
783                        thinking.to_string(),
784                    );
785                }
786
787                message.streaming = obj
788                    .get("streaming")
789                    .and_then(|st| st.as_bool())
790                    .unwrap_or_default();
791
792                message.is_error = obj.get("is_error").and_then(|v| v.as_bool());
793
794                message.stop_reason = obj
795                    .get("stop_reason")
796                    .and_then(|v| v.as_str())
797                    .map(|s| s.to_string());
798
799                // Lenient: an unparseable usage value becomes None rather
800                // than failing the whole conversion.
801                message.usage = obj
802                    .get("usage")
803                    .and_then(|v| serde_json::from_value(v.to_json()).ok());
804
805                if let Some(tool_name) = obj.get("tool_name") {
806                    message.tool_name = Some(
807                        tool_name
808                            .as_str()
809                            .ok_or_else(|| {
810                                AgentError::InvalidValue(
811                                    "'tool_name' field must be a string".to_string(),
812                                )
813                            })?
814                            .to_string(),
815                    );
816                }
817
818                if let Some(tool_calls) = obj.get("tool_calls") {
819                    let mut calls = vec![];
820                    for call_value in tool_calls.as_array().ok_or_else(|| {
821                        AgentError::InvalidValue("'tool_calls' field must be an array".to_string())
822                    })? {
823                        let id = call_value
824                            .get("id")
825                            .and_then(|i| i.as_str())
826                            .map(|s| s.to_string());
827                        let function = call_value.get("function").ok_or_else(|| {
828                            AgentError::InvalidValue(
829                                "Tool call missing 'function' field".to_string(),
830                            )
831                        })?;
832                        let tool_name = function.get_str("name").ok_or_else(|| {
833                            AgentError::InvalidValue(
834                                "Tool call function missing 'name' field".to_string(),
835                            )
836                        })?;
837                        let parameters = function.get("parameters").ok_or_else(|| {
838                            AgentError::InvalidValue(
839                                "Tool call function missing 'parameters' field".to_string(),
840                            )
841                        })?;
842                        let call = ToolCall {
843                            function: ToolCallFunction {
844                                id,
845                                name: tool_name.to_string(),
846                                parameters: parameters.to_json(),
847                                parse_error: None,
848                            },
849                        };
850                        calls.push(call);
851                    }
852                    message.tool_calls = Some(calls.into());
853                }
854
855                #[cfg(feature = "image")]
856                {
857                    if let Some(image_value) = obj.get("image") {
858                        match image_value {
859                            AgentValue::String(s) => {
860                                message.image = Some(Arc::new(PhotonImage::new_from_base64(
861                                    s.trim_start_matches("data:image/png;base64,"),
862                                )));
863                            }
864                            AgentValue::Image(img) => {
865                                message.image = Some(img.clone());
866                            }
867                            _ => {}
868                        }
869                    }
870                }
871
872                Ok(message)
873            }
874            _ => Err(AgentError::InvalidValue(
875                "Cannot convert AgentValue to Message".to_string(),
876            )),
877        }
878    }
879}
880
881impl From<Message> for AgentValue {
882    fn from(msg: Message) -> Self {
883        AgentValue::Message(Arc::new(msg))
884    }
885}
886
887impl From<Vec<Message>> for AgentValue {
888    fn from(msgs: Vec<Message>) -> Self {
889        let agent_msgs: Vector<AgentValue> = msgs.into_iter().map(|m| m.into()).collect();
890        AgentValue::Array(agent_msgs)
891    }
892}
893
894#[cfg(test)]
895mod tests {
896    use im::{hashmap, vector};
897
898    use super::*;
899
900    // Message tests
901
902    #[test]
903    fn test_tool_call_function_parse_error_serde() {
904        // None must not emit the key, so presets saved before this field
905        // existed round-trip unchanged.
906        let func = ToolCallFunction {
907            name: "t".to_string(),
908            parameters: serde_json::json!({}),
909            id: Some("call1".to_string()),
910            parse_error: None,
911        };
912        let json = serde_json::to_value(&func).unwrap();
913        assert!(json.get("parse_error").is_none());
914        let restored: ToolCallFunction = serde_json::from_value(json).unwrap();
915        assert_eq!(restored.parse_error, None);
916
917        // Some round-trips.
918        let func = ToolCallFunction {
919            name: "t".to_string(),
920            parameters: serde_json::json!({}),
921            id: Some("call1".to_string()),
922            parse_error: Some("bad json".to_string()),
923        };
924        let json = serde_json::to_value(&func).unwrap();
925        assert_eq!(
926            json.get("parse_error").and_then(|v| v.as_str()),
927            Some("bad json")
928        );
929        let restored: ToolCallFunction = serde_json::from_value(json).unwrap();
930        assert_eq!(restored.parse_error.as_deref(), Some("bad json"));
931    }
932
933    #[test]
934    fn test_message_to_from_agent_value() {
935        let msg = Message::user("What is the weather today?".to_string());
936
937        let value: AgentValue = msg.into();
938        assert!(value.is_message());
939        let msg_ref = value.as_message().unwrap();
940        assert_eq!(msg_ref.role, "user");
941        assert_eq!(msg_ref.text(), "What is the weather today?");
942
943        let msg_converted: Message = value.try_into().unwrap();
944        assert_eq!(msg_converted.role, "user");
945        assert_eq!(msg_converted.text(), "What is the weather today?");
946    }
947
948    #[test]
949    fn test_message_with_tool_calls_to_from_agent_value() {
950        let mut msg = Message::assistant("".to_string());
951        msg.tool_calls = Some(vector![ToolCall {
952            function: ToolCallFunction {
953                id: Some("call1".to_string()),
954                name: "get_weather".to_string(),
955                parameters: serde_json::json!({"location": "San Francisco"}),
956                parse_error: None,
957            },
958        }]);
959
960        let value: AgentValue = msg.into();
961        assert!(value.is_message());
962        let msg_ref = value.as_message().unwrap();
963        assert_eq!(msg_ref.role, "assistant");
964        assert_eq!(msg_ref.text(), "");
965        let tool_calls = msg_ref.tool_calls.as_ref().unwrap();
966        assert_eq!(tool_calls.len(), 1);
967        let first_call = &tool_calls[0];
968        assert_eq!(first_call.function.name, "get_weather");
969        assert_eq!(first_call.function.parameters["location"], "San Francisco");
970
971        let msg_converted: Message = value.try_into().unwrap();
972        dbg!(&msg_converted);
973        assert_eq!(msg_converted.role, "assistant");
974        assert_eq!(msg_converted.text(), "");
975        let tool_calls = msg_converted.tool_calls.unwrap();
976        assert_eq!(tool_calls.len(), 1);
977        assert_eq!(tool_calls[0].function.name, "get_weather");
978        assert_eq!(
979            tool_calls[0].function.parameters,
980            serde_json::json!({"location": "San Francisco"})
981        );
982    }
983
984    #[test]
985    fn test_tool_message_to_from_agent_value() {
986        let msg = Message::tool("get_time".to_string(), "2025-01-02 03:04:05".to_string());
987
988        let value: AgentValue = msg.clone().into();
989        let msg_ref = value.as_message().unwrap();
990        assert_eq!(msg_ref.role, "tool");
991        assert_eq!(msg_ref.tool_name.as_deref().unwrap(), "get_time");
992        assert_eq!(msg_ref.text(), "2025-01-02 03:04:05");
993
994        let msg_converted: Message = value.try_into().unwrap();
995        assert_eq!(msg_converted.role, "tool");
996        assert_eq!(msg_converted.tool_name.as_deref(), Some("get_time"));
997        assert_eq!(msg_converted.text(), "2025-01-02 03:04:05");
998    }
999
1000    #[test]
1001    fn test_message_from_string_value() {
1002        let value = AgentValue::string("Just a simple message");
1003        let msg: Message = value.try_into().unwrap();
1004        assert_eq!(msg.role, "user");
1005        assert_eq!(msg.text(), "Just a simple message");
1006    }
1007
1008    #[test]
1009    fn test_message_from_object_value() {
1010        let value = AgentValue::object(hashmap! {
1011            "role".into() => AgentValue::string("assistant"),
1012                "content".into() =>
1013                AgentValue::string("Here is some information."),
1014        });
1015        let msg: Message = value.try_into().unwrap();
1016        assert_eq!(msg.role, "assistant");
1017        assert_eq!(msg.text(), "Here is some information.");
1018    }
1019
1020    #[test]
1021    fn test_message_from_object_value_reads_is_error() {
1022        let value = AgentValue::object(hashmap! {
1023            "role".into() => AgentValue::string("tool"),
1024            "content".into() => AgentValue::string("boom"),
1025            "tool_name".into() => AgentValue::string("failing_tool"),
1026            "is_error".into() => AgentValue::boolean(true),
1027        });
1028        let msg: Message = value.try_into().unwrap();
1029        assert_eq!(msg.is_error, Some(true));
1030    }
1031
1032    #[test]
1033    fn test_message_from_invalid_value() {
1034        let value = AgentValue::integer(42);
1035        let result: Result<Message, AgentError> = value.try_into();
1036        assert!(result.is_err());
1037    }
1038
1039    #[test]
1040    fn test_message_invalid_object() {
1041        let value =
1042            AgentValue::object(hashmap! {"some_key".into() => AgentValue::string("some_value")});
1043        let result: Result<Message, AgentError> = value.try_into();
1044        assert!(result.is_err());
1045    }
1046
1047    #[test]
1048    fn test_message_to_agent_value_with_tool_calls() {
1049        let message = Message {
1050            role: "assistant".to_string(),
1051            content: MessageContent::default(),
1052            tokens: None,
1053            streaming: false,
1054            tool_calls: Some(vector![ToolCall {
1055                function: ToolCallFunction {
1056                    id: Some("call1".to_string()),
1057                    name: "active_applications".to_string(),
1058                    parameters: serde_json::json!({}),
1059                    parse_error: None,
1060                },
1061            }]),
1062            id: None,
1063            tool_name: None,
1064            is_error: None,
1065            stop_reason: None,
1066            usage: None,
1067            #[cfg(feature = "image")]
1068            image: None,
1069        };
1070
1071        let value: AgentValue = message.into();
1072        let msg_ref = value.as_message().unwrap();
1073
1074        assert_eq!(msg_ref.role, "assistant");
1075        assert_eq!(msg_ref.text(), "");
1076
1077        let tool_calls = msg_ref.tool_calls.as_ref().unwrap();
1078        assert_eq!(tool_calls.len(), 1);
1079
1080        assert_eq!(tool_calls[0].function.name, "active_applications");
1081        assert!(
1082            tool_calls[0]
1083                .function
1084                .parameters
1085                .as_object()
1086                .unwrap()
1087                .is_empty()
1088        );
1089    }
1090
1091    #[test]
1092    fn test_message_is_error_serde_round_trip() {
1093        let mut msg = Message::tool("failing_tool".to_string(), "boom".to_string());
1094        msg.id = Some("call1".to_string());
1095        msg.is_error = Some(true);
1096
1097        let json = serde_json::to_value(&msg).unwrap();
1098        assert_eq!(json["is_error"], serde_json::json!(true));
1099
1100        let restored: Message = serde_json::from_value(json).unwrap();
1101        assert_eq!(restored.is_error, Some(true));
1102        assert_eq!(restored.id.as_deref(), Some("call1"));
1103        assert_eq!(restored.tool_name.as_deref(), Some("failing_tool"));
1104    }
1105
1106    #[test]
1107    fn test_message_without_is_error_deserializes_to_none() {
1108        let json = serde_json::json!({
1109            "role": "tool",
1110            "content": "ok",
1111            "tool_name": "some_tool",
1112        });
1113        let msg: Message = serde_json::from_value(json).unwrap();
1114        assert_eq!(msg.is_error, None);
1115    }
1116
1117    #[test]
1118    fn test_message_is_error_none_serializes_without_key() {
1119        let msg = Message::tool("some_tool".to_string(), "ok".to_string());
1120        assert_eq!(msg.is_error, None);
1121
1122        let json = serde_json::to_value(&msg).unwrap();
1123        assert!(json.as_object().unwrap().get("is_error").is_none());
1124    }
1125
1126    #[test]
1127    fn test_message_stop_reason_serde_round_trip() {
1128        let mut msg = Message::assistant("partial answer".to_string());
1129        msg.stop_reason = Some("length".to_string());
1130
1131        let json = serde_json::to_value(&msg).unwrap();
1132        assert_eq!(json["stop_reason"], serde_json::json!("length"));
1133
1134        let restored: Message = serde_json::from_value(json).unwrap();
1135        assert_eq!(restored.stop_reason.as_deref(), Some("length"));
1136    }
1137
1138    #[test]
1139    fn test_message_without_stop_reason_deserializes_to_none() {
1140        // Presets saved before this field existed must load unchanged.
1141        let json = serde_json::json!({
1142            "role": "assistant",
1143            "content": "ok",
1144        });
1145        let msg: Message = serde_json::from_value(json).unwrap();
1146        assert_eq!(msg.stop_reason, None);
1147    }
1148
1149    #[test]
1150    fn test_message_stop_reason_none_serializes_without_key() {
1151        let msg = Message::assistant("ok".to_string());
1152        assert_eq!(msg.stop_reason, None);
1153
1154        let json = serde_json::to_value(&msg).unwrap();
1155        assert!(json.as_object().unwrap().get("stop_reason").is_none());
1156    }
1157
1158    #[test]
1159    fn test_message_from_object_value_reads_stop_reason() {
1160        let value = AgentValue::object(hashmap! {
1161            "role".into() => AgentValue::string("assistant"),
1162            "content".into() => AgentValue::string("truncated"),
1163            "stop_reason".into() => AgentValue::string("length"),
1164        });
1165        let msg: Message = value.try_into().unwrap();
1166        assert_eq!(msg.stop_reason.as_deref(), Some("length"));
1167    }
1168
1169    #[test]
1170    fn test_message_usage_serde_round_trip() {
1171        let mut msg = Message::assistant("ok".to_string());
1172        msg.usage = Some(Usage {
1173            input_tokens: 100,
1174            output_tokens: 20,
1175            cache_read_tokens: 50,
1176            cache_write_tokens: 10,
1177        });
1178
1179        let json = serde_json::to_value(&msg).unwrap();
1180        assert_eq!(
1181            json["usage"],
1182            serde_json::json!({
1183                "input_tokens": 100,
1184                "output_tokens": 20,
1185                "cache_read_tokens": 50,
1186                "cache_write_tokens": 10,
1187            })
1188        );
1189
1190        let restored: Message = serde_json::from_value(json).unwrap();
1191        assert_eq!(restored.usage, msg.usage);
1192    }
1193
1194    #[test]
1195    fn test_message_without_usage_deserializes_to_none() {
1196        // Presets saved before this field existed must load unchanged.
1197        let json = serde_json::json!({
1198            "role": "assistant",
1199            "content": "ok",
1200        });
1201        let msg: Message = serde_json::from_value(json).unwrap();
1202        assert_eq!(msg.usage, None);
1203    }
1204
1205    #[test]
1206    fn test_message_usage_none_serializes_without_key() {
1207        let msg = Message::assistant("ok".to_string());
1208        assert_eq!(msg.usage, None);
1209
1210        let json = serde_json::to_value(&msg).unwrap();
1211        assert!(json.as_object().unwrap().get("usage").is_none());
1212    }
1213
1214    #[test]
1215    fn test_message_from_object_value_reads_usage() {
1216        let value = AgentValue::object(hashmap! {
1217            "role".into() => AgentValue::string("assistant"),
1218            "content".into() => AgentValue::string("ok"),
1219            "usage".into() => AgentValue::object(hashmap! {
1220                "input_tokens".into() => AgentValue::integer(7),
1221                "output_tokens".into() => AgentValue::integer(3),
1222            }),
1223        });
1224        let msg: Message = value.try_into().unwrap();
1225        assert_eq!(
1226            msg.usage,
1227            Some(Usage {
1228                input_tokens: 7,
1229                output_tokens: 3,
1230                cache_read_tokens: 0,
1231                cache_write_tokens: 0,
1232            })
1233        );
1234    }
1235
1236    #[test]
1237    fn test_message_partial_usage_object_deserializes_with_defaults() {
1238        let json = serde_json::json!({
1239            "role": "assistant",
1240            "content": "ok",
1241            "usage": { "input_tokens": 42 },
1242        });
1243        let msg: Message = serde_json::from_value(json).unwrap();
1244        assert_eq!(
1245            msg.usage,
1246            Some(Usage {
1247                input_tokens: 42,
1248                output_tokens: 0,
1249                cache_read_tokens: 0,
1250                cache_write_tokens: 0,
1251            })
1252        );
1253    }
1254
1255    #[test]
1256    fn test_message_unparseable_usage_deserializes_to_none() {
1257        let json = serde_json::json!({
1258            "role": "assistant",
1259            "content": "ok",
1260            "usage": "not an object",
1261        });
1262        let msg: Message = serde_json::from_value(json).unwrap();
1263        assert_eq!(msg.usage, None);
1264    }
1265
1266    // MessageEvent tests
1267
1268    #[test]
1269    fn test_message_event_text_delta_serde_round_trip() {
1270        let mut partial = Message::assistant("Hel".to_string());
1271        partial.streaming = true;
1272        let event = MessageEvent::TextDelta {
1273            delta: "l".to_string(),
1274            partial,
1275        };
1276
1277        let json = serde_json::to_value(&event).unwrap();
1278        assert_eq!(json["type"], serde_json::json!("text_delta"));
1279        assert_eq!(json["delta"], serde_json::json!("l"));
1280        assert_eq!(json["partial"]["content"], serde_json::json!("Hel"));
1281
1282        let restored: MessageEvent = serde_json::from_value(json).unwrap();
1283        assert_eq!(restored, event);
1284        // Message's PartialEq covers only id/role/content, so fields the
1285        // handwritten serde must preserve are asserted directly.
1286        let MessageEvent::TextDelta { delta, partial } = restored else {
1287            panic!("wrong variant");
1288        };
1289        assert_eq!(delta, "l");
1290        assert!(partial.streaming);
1291    }
1292
1293    #[test]
1294    fn test_message_event_done_serde_round_trip() {
1295        let mut msg = Message::assistant("Hello".to_string());
1296        msg.id = Some("msg1".to_string());
1297        msg.stop_reason = Some("stop".to_string());
1298        msg.usage = Some(Usage {
1299            input_tokens: 10,
1300            output_tokens: 5,
1301            cache_read_tokens: 2,
1302            cache_write_tokens: 1,
1303        });
1304        let event = MessageEvent::Done {
1305            message: msg.clone(),
1306        };
1307
1308        let json = serde_json::to_value(&event).unwrap();
1309        assert_eq!(json["type"], serde_json::json!("done"));
1310        assert_eq!(json["message"]["role"], serde_json::json!("assistant"));
1311        assert_eq!(json["message"]["content"], serde_json::json!("Hello"));
1312
1313        let restored: MessageEvent = serde_json::from_value(json).unwrap();
1314        assert_eq!(restored, event);
1315        // Message's PartialEq covers only id/role/content, so fields the
1316        // handwritten serde must preserve are asserted directly.
1317        let MessageEvent::Done { message } = restored else {
1318            panic!("wrong variant");
1319        };
1320        assert!(!message.streaming);
1321        assert_eq!(message.stop_reason, msg.stop_reason);
1322        assert_eq!(message.usage, msg.usage);
1323    }
1324
1325    #[test]
1326    fn test_message_event_tool_call_end_serde_round_trip() {
1327        let tool_call = ToolCall {
1328            function: ToolCallFunction {
1329                id: Some("call1".to_string()),
1330                name: "get_weather".to_string(),
1331                parameters: serde_json::json!({"location": "Tokyo"}),
1332                parse_error: None,
1333            },
1334        };
1335        let mut partial = Message::assistant("".to_string());
1336        partial.streaming = true;
1337        partial.tool_calls = Some(vector![tool_call.clone()]);
1338        let event = MessageEvent::ToolCallEnd {
1339            index: 0,
1340            tool_call,
1341            partial,
1342        };
1343
1344        let json = serde_json::to_value(&event).unwrap();
1345        assert_eq!(json["type"], serde_json::json!("tool_call_end"));
1346        assert_eq!(json["index"], serde_json::json!(0));
1347        assert_eq!(
1348            json["tool_call"]["function"]["name"],
1349            serde_json::json!("get_weather")
1350        );
1351
1352        let restored: MessageEvent = serde_json::from_value(json).unwrap();
1353        assert_eq!(restored, event);
1354        // Message's PartialEq covers only id/role/content, so fields the
1355        // handwritten serde must preserve are asserted directly.
1356        let MessageEvent::ToolCallEnd {
1357            index,
1358            tool_call,
1359            partial,
1360        } = restored
1361        else {
1362            panic!("wrong variant");
1363        };
1364        assert_eq!(index, 0);
1365        assert_eq!(tool_call.function.name, "get_weather");
1366        assert_eq!(
1367            tool_call.function.parameters,
1368            serde_json::json!({"location": "Tokyo"})
1369        );
1370        assert!(partial.streaming);
1371        let restored_calls = partial.tool_calls.unwrap();
1372        assert_eq!(restored_calls.len(), 1);
1373        assert_eq!(restored_calls[0].function.id, Some("call1".to_string()));
1374    }
1375
1376    #[test]
1377    fn test_message_event_to_agent_value() {
1378        let event = MessageEvent::Done {
1379            message: Message::assistant("Hello".to_string()),
1380        };
1381
1382        let value = AgentValue::try_from(event).unwrap();
1383        assert!(value.is_object());
1384        assert_eq!(value.get_str("type"), Some("done"));
1385        let message = value.get("message").unwrap();
1386        assert_eq!(message.get_str("role"), Some("assistant"));
1387        assert_eq!(message.get_str("content"), Some("Hello"));
1388    }
1389
1390    #[test]
1391    fn test_message_event_error_to_agent_value() {
1392        let event = MessageEvent::Error {
1393            message: Message::assistant("partial".to_string()),
1394            error: "connection reset".to_string(),
1395        };
1396
1397        let value = AgentValue::try_from(event).unwrap();
1398        assert_eq!(value.get_str("type"), Some("error"));
1399        assert_eq!(value.get_str("error"), Some("connection reset"));
1400    }
1401
1402    #[test]
1403    fn test_message_partial_eq() {
1404        let msg1 = Message::user("hello".to_string());
1405        let msg2 = Message::user("hello".to_string());
1406        let msg3 = Message::user("world".to_string());
1407
1408        assert_eq!(msg1, msg2);
1409        assert_ne!(msg1, msg3);
1410
1411        let mut msg4 = Message::user("hello".to_string());
1412        msg4.id = Some("123".to_string());
1413        assert_ne!(msg1, msg4);
1414    }
1415
1416    // Content block tests
1417
1418    #[test]
1419    fn test_message_legacy_thinking_field_absorbed_on_deserialize() {
1420        let json = serde_json::json!({
1421            "role": "assistant",
1422            "content": "hi",
1423            "thinking": "t",
1424        });
1425        let msg: Message = serde_json::from_value(json).unwrap();
1426        assert_eq!(
1427            msg.content,
1428            MessageContent::Blocks(vec![
1429                ContentBlock::Thinking {
1430                    thinking: "t".to_string(),
1431                    signature: None,
1432                    redacted: false,
1433                },
1434                ContentBlock::Text {
1435                    text: "hi".to_string()
1436                },
1437            ])
1438        );
1439        assert_eq!(msg.text(), "hi");
1440        assert_eq!(msg.thinking().as_deref(), Some("t"));
1441    }
1442
1443    #[test]
1444    fn test_message_pure_text_serializes_as_plain_string() {
1445        let msg = Message::assistant("hello".to_string());
1446        let json = serde_json::to_value(&msg).unwrap();
1447        assert_eq!(json["content"], serde_json::json!("hello"));
1448
1449        // Text-only block content is also flattened to the legacy string form.
1450        let mut msg = Message::assistant(String::new());
1451        msg.content = MessageContent::Blocks(vec![
1452            ContentBlock::Text {
1453                text: "hel".to_string(),
1454            },
1455            ContentBlock::Text {
1456                text: "lo".to_string(),
1457            },
1458        ]);
1459        let json = serde_json::to_value(&msg).unwrap();
1460        assert_eq!(json["content"], serde_json::json!("hello"));
1461    }
1462
1463    #[test]
1464    fn test_message_thinking_blocks_serde_round_trip() {
1465        let mut msg = Message::assistant(String::new());
1466        msg.content = MessageContent::Blocks(vec![
1467            ContentBlock::Thinking {
1468                thinking: "reasoning".to_string(),
1469                signature: Some("sig123".to_string()),
1470                redacted: false,
1471            },
1472            ContentBlock::Thinking {
1473                thinking: "opaque-payload".to_string(),
1474                signature: None,
1475                redacted: true,
1476            },
1477            ContentBlock::Text {
1478                text: "answer".to_string(),
1479            },
1480        ]);
1481
1482        let json = serde_json::to_value(&msg).unwrap();
1483        assert!(json["content"].is_array());
1484        // The legacy top-level "thinking" key is no longer written.
1485        assert!(json.get("thinking").is_none());
1486
1487        let restored: Message = serde_json::from_value(json).unwrap();
1488        assert_eq!(restored.content, msg.content);
1489    }
1490
1491    #[test]
1492    fn test_message_thinking_redacts_and_joins_with_newline() {
1493        // The former `thinking` field surfaced "[redacted]" for redacted
1494        // blocks and joined multiple traces with a newline; the accessor
1495        // must not leak the encrypted payload stored in redacted blocks.
1496        let mut msg = Message::assistant(String::new());
1497        msg.content = MessageContent::Blocks(vec![
1498            ContentBlock::Thinking {
1499                thinking: "Let me think...".to_string(),
1500                signature: Some("sig".to_string()),
1501                redacted: false,
1502            },
1503            ContentBlock::Thinking {
1504                thinking: "EqQBCgIYAg-ciphertext".to_string(),
1505                signature: None,
1506                redacted: true,
1507            },
1508            ContentBlock::Text {
1509                text: "answer".to_string(),
1510            },
1511        ]);
1512        assert_eq!(
1513            msg.thinking().as_deref(),
1514            Some("Let me think...\n[redacted]")
1515        );
1516    }
1517
1518    #[test]
1519    fn test_message_mixed_block_order_preserved() {
1520        let blocks = vec![
1521            ContentBlock::Text {
1522                text: "before".to_string(),
1523            },
1524            ContentBlock::Thinking {
1525                thinking: "mid".to_string(),
1526                signature: Some("s".to_string()),
1527                redacted: false,
1528            },
1529            ContentBlock::Text {
1530                text: "after".to_string(),
1531            },
1532        ];
1533        let mut msg = Message::assistant(String::new());
1534        msg.content = MessageContent::Blocks(blocks.clone());
1535
1536        let json = serde_json::to_value(&msg).unwrap();
1537        let restored: Message = serde_json::from_value(json).unwrap();
1538        assert_eq!(restored.content, MessageContent::Blocks(blocks));
1539        assert_eq!(restored.text(), "beforeafter");
1540        assert_eq!(restored.thinking().as_deref(), Some("mid"));
1541    }
1542
1543    // Token estimation tests
1544
1545    #[test]
1546    fn test_estimate_message_tokens_rounds_up() {
1547        // 5 chars / 4 rounds up to 2.
1548        let msg = Message::user("hello".to_string());
1549        assert_eq!(estimate_message_tokens(&msg), 2);
1550
1551        // 4 chars is exactly 1 token.
1552        let msg = Message::user("abcd".to_string());
1553        assert_eq!(estimate_message_tokens(&msg), 1);
1554    }
1555
1556    #[test]
1557    fn test_estimate_message_tokens_counts_tool_calls() {
1558        let parameters = serde_json::json!({"location": "Tokyo"});
1559        let mut msg = Message::assistant(String::new());
1560        msg.tool_calls = Some(vector![ToolCall {
1561            function: ToolCallFunction {
1562                id: Some("call1".to_string()),
1563                name: "get_weather".to_string(),
1564                parameters: parameters.clone(),
1565                parse_error: None,
1566            },
1567        }]);
1568
1569        let chars = "get_weather".len() + serde_json::to_string(&parameters).unwrap().len();
1570        assert_eq!(estimate_message_tokens(&msg), (chars as u64).div_ceil(4));
1571    }
1572
1573    #[test]
1574    fn test_estimate_message_tokens_counts_thinking_blocks() {
1575        let mut msg = Message::assistant(String::new());
1576        msg.content = MessageContent::Blocks(vec![
1577            ContentBlock::Thinking {
1578                thinking: "abcd".to_string(),
1579                signature: None,
1580                redacted: false,
1581            },
1582            // Redacted payloads are replayed to the provider, so they count.
1583            ContentBlock::Thinking {
1584                thinking: "wxyz".to_string(),
1585                signature: None,
1586                redacted: true,
1587            },
1588            ContentBlock::Text {
1589                text: "efgh".to_string(),
1590            },
1591        ]);
1592        assert_eq!(estimate_message_tokens(&msg), 3);
1593    }
1594
1595    #[cfg(feature = "image")]
1596    #[test]
1597    fn test_estimate_message_tokens_image_block_adds_flat_cost() {
1598        let mut msg = Message::user(String::new());
1599        msg.content = MessageContent::Blocks(vec![
1600            ContentBlock::Text {
1601                text: "abcd".to_string(),
1602            },
1603            ContentBlock::Image {
1604                data: "base64-payload-not-counted-as-chars".to_string(),
1605                mime_type: "image/png".to_string(),
1606            },
1607        ]);
1608        assert_eq!(estimate_message_tokens(&msg), 1 + 1200);
1609    }
1610
1611    #[test]
1612    fn test_estimate_context_tokens_anchors_on_latest_usage() {
1613        let mut anchored = Message::assistant("answer".to_string());
1614        anchored.usage = Some(Usage {
1615            input_tokens: 100,
1616            output_tokens: 20,
1617            cache_read_tokens: 50,
1618            cache_write_tokens: 10,
1619        });
1620        // Covered by the anchor, must not be estimated.
1621        let earlier = Message::user("long history covered by the anchor".to_string());
1622        // 8 chars -> 2 tokens estimated on top of the anchor.
1623        let trailing = Message::user("12345678".to_string());
1624
1625        let messages = vec![earlier, anchored, trailing];
1626        assert_eq!(estimate_context_tokens(&messages), 180 + 2);
1627    }
1628
1629    #[test]
1630    fn test_estimate_context_tokens_sums_all_without_usage() {
1631        let messages = vec![
1632            Message::user("abcd".to_string()),          // 1 token
1633            Message::assistant("efghijkl".to_string()), // 2 tokens
1634        ];
1635        assert_eq!(estimate_context_tokens(&messages), 3);
1636    }
1637
1638    #[test]
1639    fn test_estimate_context_tokens_usage_on_last_message() {
1640        let mut msg = Message::assistant("whatever".to_string());
1641        msg.usage = Some(Usage {
1642            input_tokens: 7,
1643            output_tokens: 3,
1644            cache_read_tokens: 0,
1645            cache_write_tokens: 0,
1646        });
1647        let messages = vec![Message::user("earlier".to_string()), msg];
1648        assert_eq!(estimate_context_tokens(&messages), 10);
1649    }
1650}