Skip to main content

llm_stack/
chat.rs

1//! Conversation primitives: messages, content blocks, and responses.
2//!
3//! A conversation is a sequence of [`ChatMessage`]s, each carrying a
4//! [`ChatRole`] and one or more [`ContentBlock`]s. Providers return a
5//! [`ChatResponse`] containing the assistant's reply along with token
6//! usage and stop-reason metadata.
7//!
8//! # Content model
9//!
10//! Every message holds a `Vec<ContentBlock>` rather than a plain string.
11//! This uniform representation handles text, images, tool calls, tool
12//! results, and reasoning traces without special-casing:
13//!
14//! ```rust
15//! use llm_stack::{ChatMessage, ContentBlock, ChatRole};
16//!
17//! // Simple text message
18//! let msg = ChatMessage::user("Hello, world!");
19//!
20//! // Mixed content (text + image) in a single message
21//! let mixed = ChatMessage {
22//!     role: ChatRole::User,
23//!     content: vec![
24//!         ContentBlock::Text("What's in this image?".into()),
25//!         ContentBlock::Image {
26//!             media_type: "image/png".into(),
27//!             data: llm_stack::ImageSource::Base64("...".into()),
28//!         },
29//!     ],
30//! };
31//! ```
32
33use std::collections::HashMap;
34use std::fmt;
35
36use serde::{Deserialize, Serialize};
37use serde_json::Value;
38
39use crate::usage::Usage;
40
41/// The role of a participant in a conversation.
42///
43/// Most conversations follow the pattern `System → User → Assistant`,
44/// with [`ChatRole::Tool`] appearing only when the assistant invokes a
45/// tool and the caller feeds back the result.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
47#[non_exhaustive]
48pub enum ChatRole {
49    /// Sets behavioral instructions for the assistant. Typically the
50    /// first message in a conversation; not all providers surface it as
51    /// a discrete message (some use a separate `system` parameter).
52    System,
53    /// A message from the human user.
54    User,
55    /// A message generated by the model.
56    Assistant,
57    /// A tool result fed back to the model after it made a tool call.
58    Tool,
59}
60
61impl fmt::Display for ChatRole {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            Self::System => f.write_str("system"),
65            Self::User => f.write_str("user"),
66            Self::Assistant => f.write_str("assistant"),
67            Self::Tool => f.write_str("tool"),
68        }
69    }
70}
71
72/// A single message in a conversation.
73///
74/// Use the convenience constructors for common cases:
75///
76/// ```rust
77/// use llm_stack::ChatMessage;
78///
79/// let user  = ChatMessage::user("What is 2+2?");
80/// let asst  = ChatMessage::assistant("4");
81/// let sys   = ChatMessage::system("You are a math tutor.");
82/// let tool  = ChatMessage::tool_result("call_123", "4");
83/// ```
84///
85/// For multi-block content (images, multiple tool results), construct
86/// the `content` vec directly.
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct ChatMessage {
89    /// Who sent this message.
90    pub role: ChatRole,
91    /// The content blocks that make up the message body.
92    pub content: Vec<ContentBlock>,
93}
94
95impl ChatMessage {
96    /// Creates a message with a single [`ContentBlock::Text`] block.
97    pub fn text(role: ChatRole, text: impl Into<String>) -> Self {
98        Self {
99            role,
100            content: vec![ContentBlock::Text(text.into())],
101        }
102    }
103
104    /// Shorthand for a [`ChatRole::User`] text message.
105    pub fn user(text: impl Into<String>) -> Self {
106        Self::text(ChatRole::User, text)
107    }
108
109    /// Shorthand for a [`ChatRole::Assistant`] text message.
110    pub fn assistant(text: impl Into<String>) -> Self {
111        Self::text(ChatRole::Assistant, text)
112    }
113
114    /// Shorthand for a [`ChatRole::System`] text message.
115    pub fn system(text: impl Into<String>) -> Self {
116        Self::text(ChatRole::System, text)
117    }
118
119    /// Creates a [`ChatRole::Tool`] message with a single successful
120    /// [`ToolResult`].
121    ///
122    /// Use [`ChatMessage::tool_error`] when the tool invocation failed.
123    pub fn tool_result(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
124        Self {
125            role: ChatRole::Tool,
126            content: vec![ContentBlock::ToolResult(ToolResult {
127                tool_call_id: tool_call_id.into(),
128                content: content.into(),
129                is_error: false,
130            })],
131        }
132    }
133
134    /// Creates a [`ChatRole::Tool`] message indicating the tool call
135    /// failed. The `content` should describe the error.
136    pub fn tool_error(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
137        Self {
138            role: ChatRole::Tool,
139            content: vec![ContentBlock::ToolResult(ToolResult {
140                tool_call_id: tool_call_id.into(),
141                content: content.into(),
142                is_error: true,
143            })],
144        }
145    }
146
147    /// Returns `true` if the message has zero content blocks.
148    ///
149    /// This checks the block count only — a message containing a single
150    /// `ContentBlock::Text("")` returns `false`. Use this to detect
151    /// structurally empty messages (no blocks at all).
152    pub fn is_empty(&self) -> bool {
153        self.content.is_empty()
154    }
155
156    /// Serializes the message to a stable JSON format.
157    ///
158    /// This format is documented and versioned for persistence use cases
159    /// (conversation history, checkpointing, etc.). The format is guaranteed
160    /// to be backward compatible within the same major version.
161    ///
162    /// # Stability Guarantees
163    ///
164    /// - Field names are stable within major versions
165    /// - New optional fields may be added in minor versions
166    /// - The output is deterministic for the same input
167    ///
168    /// # Example
169    ///
170    /// ```rust
171    /// use llm_stack::ChatMessage;
172    ///
173    /// let msg = ChatMessage::user("Hello, world!");
174    /// let json = msg.to_json().expect("serialization should succeed");
175    /// assert!(json["content"][0]["text"].as_str() == Some("Hello, world!"));
176    /// ```
177    pub fn to_json(&self) -> Result<Value, serde_json::Error> {
178        serde_json::to_value(self)
179    }
180
181    /// Deserializes a message from the stable JSON format.
182    ///
183    /// Accepts JSON produced by [`to_json`](Self::to_json). This method is
184    /// the inverse of `to_json` and maintains roundtrip fidelity.
185    ///
186    /// # Example
187    ///
188    /// ```rust
189    /// use llm_stack::ChatMessage;
190    /// use serde_json::json;
191    ///
192    /// let json = json!({
193    ///     "role": "User",
194    ///     "content": [{"text": "Hello!"}]
195    /// });
196    /// let msg = ChatMessage::from_json(&json).expect("valid message");
197    /// assert_eq!(msg.role, llm_stack::ChatRole::User);
198    /// ```
199    pub fn from_json(value: &Value) -> Result<Self, serde_json::Error> {
200        serde_json::from_value(value.clone())
201    }
202
203    /// Deserializes a message from the stable JSON format, consuming the value.
204    ///
205    /// This is more efficient than [`from_json`](Self::from_json) when you have
206    /// an owned `Value` that you don't need afterward, as it avoids cloning.
207    ///
208    /// # Example
209    ///
210    /// ```rust
211    /// use llm_stack::ChatMessage;
212    /// use serde_json::json;
213    ///
214    /// let json = json!({
215    ///     "role": "User",
216    ///     "content": [{"text": "Hello!"}]
217    /// });
218    /// let msg = ChatMessage::from_json_owned(json).expect("valid message");
219    /// assert_eq!(msg.role, llm_stack::ChatRole::User);
220    /// ```
221    pub fn from_json_owned(value: Value) -> Result<Self, serde_json::Error> {
222        serde_json::from_value(value)
223    }
224}
225
226/// An individual piece of content within a [`ChatMessage`].
227///
228/// Messages are composed of one or more blocks, allowing a single
229/// message to carry text alongside images, tool invocations, or
230/// reasoning traces.
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232#[serde(rename_all = "snake_case")]
233#[non_exhaustive]
234pub enum ContentBlock {
235    /// Plain text content.
236    Text(String),
237    /// An inline image, either base64-encoded or referenced by URL.
238    Image {
239        /// MIME type, e.g. `"image/png"` or `"image/jpeg"`.
240        media_type: String,
241        /// The image payload.
242        data: ImageSource,
243    },
244    /// A tool invocation requested by the assistant.
245    ToolCall(ToolCall),
246    /// The result of executing a tool, sent back to the model.
247    ToolResult(ToolResult),
248    /// An internal reasoning step (chain-of-thought) emitted by the
249    /// model. Not all providers support this.
250    Reasoning {
251        /// The reasoning text.
252        content: String,
253    },
254}
255
256/// How an image is supplied to the model.
257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
258#[non_exhaustive]
259pub enum ImageSource {
260    /// Raw image bytes encoded as a base64 string.
261    Base64(String),
262    /// A publicly accessible URL pointing to the image.
263    ///
264    /// Stores a parsed [`url::Url`] that is guaranteed to be valid.
265    /// Use [`ImageSource::from_url`] to construct from a string.
266    Url(url::Url),
267}
268
269impl ImageSource {
270    /// Creates a [`Url`](Self::Url) variant after parsing and
271    /// normalizing the URL.
272    ///
273    /// Returns the parse error if the string is not a valid URL.
274    pub fn from_url(url: impl AsRef<str>) -> Result<Self, url::ParseError> {
275        let parsed = url::Url::parse(url.as_ref())?;
276        Ok(Self::Url(parsed))
277    }
278}
279
280/// A tool invocation requested by the assistant.
281///
282/// After receiving a `ToolCall`, the caller should execute the named
283/// tool with the given `arguments`, then feed the result back as a
284/// [`ChatMessage::tool_result`] (or [`ChatMessage::tool_error`]).
285#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
286pub struct ToolCall {
287    /// Provider-assigned identifier that links this call to its result.
288    pub id: String,
289    /// The name of the tool to invoke.
290    pub name: String,
291    /// JSON arguments to pass to the tool.
292    pub arguments: Value,
293}
294
295/// The result of executing a tool, returned to the model.
296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
297pub struct ToolResult {
298    /// The [`ToolCall::id`] this result corresponds to.
299    pub tool_call_id: String,
300    /// The tool's output (or error message if `is_error` is true).
301    pub content: String,
302    /// Whether the tool invocation failed.
303    pub is_error: bool,
304}
305
306/// A complete response from a model.
307///
308/// Returned by [`Provider::generate`](crate::Provider::generate). For
309/// streaming responses, accumulate [`StreamEvent`](crate::StreamEvent)s
310/// instead.
311#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
312pub struct ChatResponse {
313    /// The content blocks produced by the model.
314    pub content: Vec<ContentBlock>,
315    /// Token counts for this request/response pair.
316    pub usage: Usage,
317    /// Why the model stopped generating.
318    pub stop_reason: StopReason,
319    /// The model identifier that actually served the request (may differ
320    /// from the requested model if the provider performed routing).
321    pub model: String,
322    /// Provider-specific metadata (e.g. request IDs, cache info).
323    pub metadata: HashMap<String, Value>,
324}
325
326impl ChatResponse {
327    /// Creates an empty response with no content.
328    ///
329    /// Useful for timeout or error cases where no LLM response was received.
330    pub fn empty() -> Self {
331        Self {
332            content: Vec::new(),
333            usage: Usage::default(),
334            stop_reason: StopReason::EndTurn,
335            model: String::new(),
336            metadata: HashMap::new(),
337        }
338    }
339
340    /// Returns the text of the first [`ContentBlock::Text`] block, if any.
341    ///
342    /// This is a convenience for the common case where you only care
343    /// about the model's text output. For responses that may contain
344    /// multiple text blocks, iterate `content` directly.
345    pub fn text(&self) -> Option<&str> {
346        self.content.iter().find_map(|b| match b {
347            ContentBlock::Text(t) => Some(t.as_str()),
348            _ => None,
349        })
350    }
351
352    /// Returns references to all [`ToolCall`]s in the response.
353    ///
354    /// Returns an empty `Vec` when the response contains no tool calls.
355    /// This is the primary accessor for implementing tool-use loops.
356    ///
357    /// For allocation-free iteration, use [`tool_calls_iter`](Self::tool_calls_iter).
358    pub fn tool_calls(&self) -> Vec<&ToolCall> {
359        self.content
360            .iter()
361            .filter_map(|b| match b {
362                ContentBlock::ToolCall(tc) => Some(tc),
363                _ => None,
364            })
365            .collect()
366    }
367
368    /// Returns an iterator over all [`ToolCall`]s in the response.
369    ///
370    /// Prefer this over [`tool_calls`](Self::tool_calls) to avoid allocation.
371    pub fn tool_calls_iter(&self) -> impl Iterator<Item = &ToolCall> {
372        self.content.iter().filter_map(|b| match b {
373            ContentBlock::ToolCall(tc) => Some(tc),
374            _ => None,
375        })
376    }
377
378    /// Consumes the response and returns all [`ToolCall`]s.
379    ///
380    /// This is more efficient than [`tool_calls`](Self::tool_calls) when you
381    /// need owned `ToolCall` values and won't use the response afterward.
382    /// Non-tool-call content blocks are discarded.
383    ///
384    /// # Example
385    ///
386    /// ```rust
387    /// # use llm_stack::{ChatResponse, ToolCall, ContentBlock, StopReason, Usage};
388    /// # use serde_json::json;
389    /// let response = ChatResponse {
390    ///     content: vec![
391    ///         ContentBlock::Text("Let me help".into()),
392    ///         ContentBlock::ToolCall(ToolCall {
393    ///             id: "call_1".into(),
394    ///             name: "search".into(),
395    ///             arguments: json!({"query": "rust"}),
396    ///         }),
397    ///     ],
398    ///     stop_reason: StopReason::ToolUse,
399    ///     usage: Usage::default(),
400    ///     model: "test".into(),
401    ///     metadata: Default::default(),
402    /// };
403    ///
404    /// let calls = response.into_tool_calls();
405    /// assert_eq!(calls.len(), 1);
406    /// assert_eq!(calls[0].name, "search");
407    /// ```
408    pub fn into_tool_calls(self) -> Vec<ToolCall> {
409        self.content
410            .into_iter()
411            .filter_map(|b| match b {
412                ContentBlock::ToolCall(tc) => Some(tc),
413                _ => None,
414            })
415            .collect()
416    }
417
418    /// Consumes the response content and partitions it into tool calls and other blocks.
419    ///
420    /// Returns `(tool_calls, other_content)` where `other_content` contains all
421    /// non-tool-call blocks (`Text`, `Image`, `ToolResult`, etc.) suitable for building
422    /// an assistant message in a tool loop.
423    ///
424    /// This is more efficient than calling both [`tool_calls`](Self::tool_calls)
425    /// and filtering content separately, as it processes the content in a single pass.
426    pub fn partition_content(self) -> (Vec<ToolCall>, Vec<ContentBlock>) {
427        let mut tool_calls = Vec::new();
428        let mut other = Vec::new();
429
430        for block in self.content {
431            match block {
432                ContentBlock::ToolCall(tc) => tool_calls.push(tc),
433                // Filter out ToolResult as they shouldn't be in assistant messages
434                ContentBlock::ToolResult(_) => {}
435                other_block => other.push(other_block),
436            }
437        }
438
439        (tool_calls, other)
440    }
441}
442
443/// The reason the model stopped producing output.
444#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
445#[non_exhaustive]
446pub enum StopReason {
447    /// The model finished its response naturally.
448    EndTurn,
449    /// The model wants to invoke one or more tools.
450    ToolUse,
451    /// The response hit the `max_tokens` limit.
452    MaxTokens,
453    /// The model emitted a stop sequence.
454    StopSequence,
455}
456
457impl fmt::Display for StopReason {
458    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
459        match self {
460            Self::EndTurn => f.write_str("end_turn"),
461            Self::ToolUse => f.write_str("tool_use"),
462            Self::MaxTokens => f.write_str("max_tokens"),
463            Self::StopSequence => f.write_str("stop_sequence"),
464        }
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn test_chat_role_copy_hash() {
474        use std::collections::HashMap;
475        let mut map = HashMap::new();
476        let role = ChatRole::User;
477        let role_copy = role; // Copy
478        map.insert(role, "user");
479        map.insert(role_copy, "user_copy");
480        assert_eq!(map.len(), 1);
481    }
482
483    #[test]
484    fn test_chat_role_all_variants() {
485        let variants = [
486            ChatRole::System,
487            ChatRole::User,
488            ChatRole::Assistant,
489            ChatRole::Tool,
490        ];
491        for v in &variants {
492            let debug = format!("{v:?}");
493            assert!(!debug.is_empty());
494        }
495    }
496
497    #[test]
498    fn test_chat_role_serde_roundtrip() {
499        let role = ChatRole::Assistant;
500        let json = serde_json::to_string(&role).unwrap();
501        let back: ChatRole = serde_json::from_str(&json).unwrap();
502        assert_eq!(role, back);
503    }
504
505    // --- ChatMessage constructors ---
506
507    #[test]
508    fn test_user_constructor() {
509        let msg = ChatMessage::user("hello");
510        assert_eq!(msg.role, ChatRole::User);
511        assert_eq!(msg.content, vec![ContentBlock::Text("hello".into())]);
512    }
513
514    #[test]
515    fn test_assistant_constructor() {
516        let msg = ChatMessage::assistant("hi");
517        assert_eq!(msg.role, ChatRole::Assistant);
518        assert_eq!(msg.content, vec![ContentBlock::Text("hi".into())]);
519    }
520
521    #[test]
522    fn test_system_constructor() {
523        let msg = ChatMessage::system("be nice");
524        assert_eq!(msg.role, ChatRole::System);
525    }
526
527    #[test]
528    fn test_tool_result_constructor() {
529        let msg = ChatMessage::tool_result("tc_1", "42");
530        assert_eq!(msg.role, ChatRole::Tool);
531        assert!(matches!(
532            &msg.content[0],
533            ContentBlock::ToolResult(tr)
534                if tr.tool_call_id == "tc_1" && tr.content == "42" && !tr.is_error
535        ));
536    }
537
538    #[test]
539    fn test_tool_error_constructor() {
540        let msg = ChatMessage::tool_error("tc_1", "something broke");
541        assert!(matches!(
542            &msg.content[0],
543            ContentBlock::ToolResult(tr) if tr.is_error
544        ));
545    }
546
547    // --- ChatMessage clone/eq/serde ---
548
549    #[test]
550    fn test_message_text_clone_eq() {
551        let msg = ChatMessage::user("hello");
552        assert_eq!(msg, msg.clone());
553    }
554
555    #[test]
556    fn test_message_serde_roundtrip() {
557        let msg = ChatMessage::user("hello");
558        let json = serde_json::to_string(&msg).unwrap();
559        let back: ChatMessage = serde_json::from_str(&json).unwrap();
560        assert_eq!(msg, back);
561    }
562
563    #[test]
564    fn test_message_tool_use() {
565        let msg = ChatMessage {
566            role: ChatRole::Assistant,
567            content: vec![
568                ContentBlock::ToolCall(ToolCall {
569                    id: "1".into(),
570                    name: "calc".into(),
571                    arguments: serde_json::json!({"a": 1}),
572                }),
573                ContentBlock::ToolCall(ToolCall {
574                    id: "2".into(),
575                    name: "search".into(),
576                    arguments: serde_json::json!({"q": "rust"}),
577                }),
578            ],
579        };
580        assert_eq!(msg.content.len(), 2);
581        assert_eq!(msg, msg.clone());
582    }
583
584    #[test]
585    fn test_message_tool_result() {
586        let msg = ChatMessage::tool_result("1", "42");
587        assert!(matches!(
588            &msg.content[0],
589            ContentBlock::ToolResult(tr) if tr.content == "42" && !tr.is_error
590        ));
591    }
592
593    #[test]
594    fn test_message_mixed_content() {
595        let msg = ChatMessage {
596            role: ChatRole::User,
597            content: vec![
598                ContentBlock::Text("look at this".into()),
599                ContentBlock::Image {
600                    media_type: "image/png".into(),
601                    data: ImageSource::Base64("abc123".into()),
602                },
603                ContentBlock::ToolCall(ToolCall {
604                    id: "1".into(),
605                    name: "analyze".into(),
606                    arguments: serde_json::json!({}),
607                }),
608            ],
609        };
610        assert_eq!(msg.content.len(), 3);
611    }
612
613    // --- ContentBlock tests ---
614
615    #[test]
616    fn test_content_block_image_base64() {
617        let block = ContentBlock::Image {
618            media_type: "image/jpeg".into(),
619            data: ImageSource::Base64("data...".into()),
620        };
621        assert_eq!(block, block.clone());
622    }
623
624    #[test]
625    fn test_content_block_image_url() {
626        let block = ContentBlock::Image {
627            media_type: "image/png".into(),
628            data: ImageSource::from_url("https://example.com/img.png").unwrap(),
629        };
630        assert_eq!(block, block.clone());
631    }
632
633    #[test]
634    fn test_image_source_from_url_valid() {
635        let src = ImageSource::from_url("https://example.com/img.png");
636        assert!(src.is_ok());
637        let url = url::Url::parse("https://example.com/img.png").unwrap();
638        assert_eq!(src.unwrap(), ImageSource::Url(url));
639    }
640
641    #[test]
642    fn test_image_source_from_url_normalizes() {
643        // URL is parsed and normalized
644        let src = ImageSource::from_url("HTTP://EXAMPLE.COM").unwrap();
645        assert!(matches!(
646            &src,
647            ImageSource::Url(u) if u.as_str() == "http://example.com/"
648        ));
649    }
650
651    #[test]
652    fn test_image_source_from_url_invalid() {
653        let err = ImageSource::from_url("not a url");
654        assert!(err.is_err());
655        let _parse_err: url::ParseError = err.unwrap_err();
656
657        assert!(ImageSource::from_url("").is_err());
658    }
659
660    #[test]
661    fn test_content_block_reasoning() {
662        let block = ContentBlock::Reasoning {
663            content: "thinking step by step".into(),
664        };
665        assert_eq!(block, block.clone());
666    }
667
668    #[test]
669    fn test_tool_call_json_arguments() {
670        let call = ToolCall {
671            id: "tc_1".into(),
672            name: "search".into(),
673            arguments: serde_json::json!({
674                "query": "rust async",
675                "filters": {"lang": "en", "limit": 10}
676            }),
677        };
678        assert_eq!(call, call.clone());
679    }
680
681    #[test]
682    fn test_tool_result_error_flag() {
683        let ok = ToolResult {
684            tool_call_id: "1".into(),
685            content: "result".into(),
686            is_error: false,
687        };
688        let err = ToolResult {
689            tool_call_id: "1".into(),
690            content: "result".into(),
691            is_error: true,
692        };
693        assert_ne!(ok, err);
694    }
695
696    // --- ChatResponse tests ---
697
698    #[test]
699    fn test_chat_response_metadata() {
700        let mut metadata = HashMap::new();
701        metadata.insert("cost".into(), serde_json::json!({"usd": 0.01}));
702        let resp = ChatResponse {
703            content: vec![ContentBlock::Text("hi".into())],
704            usage: Usage::default(),
705            stop_reason: StopReason::EndTurn,
706            model: "test-model".into(),
707            metadata,
708        };
709        assert!(resp.metadata.contains_key("cost"));
710    }
711
712    #[test]
713    fn test_chat_response_serde_roundtrip() {
714        let resp = ChatResponse {
715            content: vec![ContentBlock::Text("hi".into())],
716            usage: Usage::default(),
717            stop_reason: StopReason::EndTurn,
718            model: "test-model".into(),
719            metadata: HashMap::new(),
720        };
721        let json = serde_json::to_string(&resp).unwrap();
722        let back: ChatResponse = serde_json::from_str(&json).unwrap();
723        assert_eq!(resp, back);
724    }
725
726    #[test]
727    fn test_chat_response_empty_content() {
728        let resp = ChatResponse {
729            content: vec![],
730            usage: Usage::default(),
731            stop_reason: StopReason::EndTurn,
732            model: "test".into(),
733            metadata: HashMap::new(),
734        };
735        assert!(resp.content.is_empty());
736    }
737
738    // --- StopReason tests ---
739
740    #[test]
741    fn test_stop_reason_all_variants() {
742        let variants = [
743            StopReason::EndTurn,
744            StopReason::ToolUse,
745            StopReason::MaxTokens,
746            StopReason::StopSequence,
747        ];
748        for v in &variants {
749            assert_eq!(*v, *v);
750        }
751    }
752
753    #[test]
754    fn test_stop_reason_serde_roundtrip() {
755        let sr = StopReason::MaxTokens;
756        let json = serde_json::to_string(&sr).unwrap();
757        let back: StopReason = serde_json::from_str(&json).unwrap();
758        assert_eq!(sr, back);
759    }
760
761    #[test]
762    fn test_stop_reason_eq_hash() {
763        use std::collections::HashMap;
764        let mut map = HashMap::new();
765        map.insert(StopReason::EndTurn, "end");
766        map.insert(StopReason::ToolUse, "tool");
767        assert_eq!(map[&StopReason::EndTurn], "end");
768        assert_eq!(map[&StopReason::ToolUse], "tool");
769    }
770
771    // --- Display impls ---
772
773    #[test]
774    fn test_chat_role_display() {
775        assert_eq!(ChatRole::System.to_string(), "system");
776        assert_eq!(ChatRole::User.to_string(), "user");
777        assert_eq!(ChatRole::Assistant.to_string(), "assistant");
778        assert_eq!(ChatRole::Tool.to_string(), "tool");
779    }
780
781    #[test]
782    fn test_stop_reason_display() {
783        assert_eq!(StopReason::EndTurn.to_string(), "end_turn");
784        assert_eq!(StopReason::ToolUse.to_string(), "tool_use");
785        assert_eq!(StopReason::MaxTokens.to_string(), "max_tokens");
786        assert_eq!(StopReason::StopSequence.to_string(), "stop_sequence");
787    }
788
789    // --- ChatResponse::text() ---
790
791    #[test]
792    fn test_chat_response_text_returns_first() {
793        let resp = ChatResponse {
794            content: vec![
795                ContentBlock::Reasoning {
796                    content: "thinking...".into(),
797                },
798                ContentBlock::Text("first".into()),
799                ContentBlock::Text("second".into()),
800            ],
801            usage: Usage::default(),
802            stop_reason: StopReason::EndTurn,
803            model: "test".into(),
804            metadata: HashMap::new(),
805        };
806        assert_eq!(resp.text(), Some("first"));
807    }
808
809    #[test]
810    fn test_chat_response_text_none_when_no_text_blocks() {
811        let resp = ChatResponse {
812            content: vec![ContentBlock::Reasoning {
813                content: "thinking".into(),
814            }],
815            usage: Usage::default(),
816            stop_reason: StopReason::EndTurn,
817            model: "test".into(),
818            metadata: HashMap::new(),
819        };
820        assert_eq!(resp.text(), None);
821    }
822
823    #[test]
824    fn test_chat_response_text_none_when_empty() {
825        let resp = ChatResponse {
826            content: vec![],
827            usage: Usage::default(),
828            stop_reason: StopReason::EndTurn,
829            model: "test".into(),
830            metadata: HashMap::new(),
831        };
832        assert_eq!(resp.text(), None);
833    }
834
835    // --- ChatResponse::tool_calls() ---
836
837    #[test]
838    fn test_chat_response_tool_calls() {
839        let resp = ChatResponse {
840            content: vec![
841                ContentBlock::Text("Let me search.".into()),
842                ContentBlock::ToolCall(ToolCall {
843                    id: "1".into(),
844                    name: "search".into(),
845                    arguments: serde_json::json!({"q": "rust"}),
846                }),
847                ContentBlock::ToolCall(ToolCall {
848                    id: "2".into(),
849                    name: "calc".into(),
850                    arguments: serde_json::json!({"expr": "2+2"}),
851                }),
852            ],
853            usage: Usage::default(),
854            stop_reason: StopReason::ToolUse,
855            model: "test".into(),
856            metadata: HashMap::new(),
857        };
858        let calls = resp.tool_calls();
859        assert_eq!(calls.len(), 2);
860        assert_eq!(calls[0].name, "search");
861        assert_eq!(calls[1].name, "calc");
862    }
863
864    #[test]
865    fn test_chat_response_tool_calls_empty_when_text_only() {
866        let resp = ChatResponse {
867            content: vec![ContentBlock::Text("hello".into())],
868            usage: Usage::default(),
869            stop_reason: StopReason::EndTurn,
870            model: "test".into(),
871            metadata: HashMap::new(),
872        };
873        assert!(resp.tool_calls().is_empty());
874    }
875
876    // --- is_empty ---
877
878    #[test]
879    fn test_message_is_empty() {
880        let empty = ChatMessage {
881            role: ChatRole::User,
882            content: vec![],
883        };
884        assert!(empty.is_empty());
885        assert!(!ChatMessage::user("hi").is_empty());
886    }
887
888    // --- ContentBlock serde wire format ---
889
890    #[test]
891    fn test_content_block_serde_text() {
892        let block = ContentBlock::Text("hello".into());
893        let val = serde_json::to_value(&block).unwrap();
894        assert_eq!(val, serde_json::json!({"text": "hello"}));
895        let back: ContentBlock = serde_json::from_value(val).unwrap();
896        assert_eq!(back, block);
897    }
898
899    #[test]
900    fn test_content_block_serde_image() {
901        let block = ContentBlock::Image {
902            media_type: "image/png".into(),
903            data: ImageSource::Base64("abc".into()),
904        };
905        let val = serde_json::to_value(&block).unwrap();
906        assert_eq!(
907            val,
908            serde_json::json!({"image": {"media_type": "image/png", "data": {"Base64": "abc"}}})
909        );
910        let back: ContentBlock = serde_json::from_value(val).unwrap();
911        assert_eq!(back, block);
912    }
913
914    #[test]
915    fn test_content_block_serde_tool_call() {
916        let block = ContentBlock::ToolCall(ToolCall {
917            id: "tc_1".into(),
918            name: "search".into(),
919            arguments: serde_json::json!({"q": "rust"}),
920        });
921        let val = serde_json::to_value(&block).unwrap();
922        assert_eq!(
923            val,
924            serde_json::json!({"tool_call": {"id": "tc_1", "name": "search", "arguments": {"q": "rust"}}})
925        );
926        let back: ContentBlock = serde_json::from_value(val).unwrap();
927        assert_eq!(back, block);
928    }
929
930    #[test]
931    fn test_content_block_serde_tool_result() {
932        let block = ContentBlock::ToolResult(ToolResult {
933            tool_call_id: "tc_1".into(),
934            content: "42".into(),
935            is_error: false,
936        });
937        let val = serde_json::to_value(&block).unwrap();
938        assert_eq!(
939            val,
940            serde_json::json!({"tool_result": {"tool_call_id": "tc_1", "content": "42", "is_error": false}})
941        );
942        let back: ContentBlock = serde_json::from_value(val).unwrap();
943        assert_eq!(back, block);
944    }
945
946    #[test]
947    fn test_content_block_serde_reasoning() {
948        let block = ContentBlock::Reasoning {
949            content: "thinking".into(),
950        };
951        let val = serde_json::to_value(&block).unwrap();
952        assert_eq!(
953            val,
954            serde_json::json!({"reasoning": {"content": "thinking"}})
955        );
956        let back: ContentBlock = serde_json::from_value(val).unwrap();
957        assert_eq!(back, block);
958    }
959
960    // --- Semantic validity: constructors produce valid role/content combinations ---
961
962    #[test]
963    fn test_user_constructor_produces_text_only() {
964        let msg = ChatMessage::user("hello");
965        assert_eq!(msg.role, ChatRole::User);
966        assert!(
967            msg.content
968                .iter()
969                .all(|b| matches!(b, ContentBlock::Text(_)))
970        );
971    }
972
973    #[test]
974    fn test_assistant_constructor_produces_text_only() {
975        let msg = ChatMessage::assistant("hi");
976        assert_eq!(msg.role, ChatRole::Assistant);
977        assert!(
978            msg.content
979                .iter()
980                .all(|b| matches!(b, ContentBlock::Text(_)))
981        );
982    }
983
984    #[test]
985    fn test_system_constructor_produces_text_only() {
986        let msg = ChatMessage::system("be nice");
987        assert_eq!(msg.role, ChatRole::System);
988        assert!(
989            msg.content
990                .iter()
991                .all(|b| matches!(b, ContentBlock::Text(_)))
992        );
993    }
994
995    #[test]
996    fn test_tool_result_constructor_produces_tool_result_only() {
997        let msg = ChatMessage::tool_result("tc_1", "42");
998        assert_eq!(msg.role, ChatRole::Tool);
999        assert!(
1000            msg.content
1001                .iter()
1002                .all(|b| matches!(b, ContentBlock::ToolResult(_)))
1003        );
1004    }
1005
1006    #[test]
1007    fn test_tool_error_constructor_produces_tool_result_only() {
1008        let msg = ChatMessage::tool_error("tc_1", "boom");
1009        assert_eq!(msg.role, ChatRole::Tool);
1010        assert!(
1011            msg.content
1012                .iter()
1013                .all(|b| matches!(b, ContentBlock::ToolResult(r) if r.is_error))
1014        );
1015    }
1016
1017    #[test]
1018    fn test_assistant_tool_calls_is_valid_combination() {
1019        // Assistants can produce tool calls — this is a valid state
1020        let msg = ChatMessage {
1021            role: ChatRole::Assistant,
1022            content: vec![
1023                ContentBlock::Text("Let me search for that.".into()),
1024                ContentBlock::ToolCall(ToolCall {
1025                    id: "1".into(),
1026                    name: "search".into(),
1027                    arguments: serde_json::json!({"q": "rust"}),
1028                }),
1029            ],
1030        };
1031        assert_eq!(msg.role, ChatRole::Assistant);
1032        assert_eq!(msg.content.len(), 2);
1033    }
1034
1035    #[test]
1036    fn test_user_with_image_is_valid_combination() {
1037        // Users can send text + images — this is a valid state
1038        let msg = ChatMessage {
1039            role: ChatRole::User,
1040            content: vec![
1041                ContentBlock::Text("What's this?".into()),
1042                ContentBlock::Image {
1043                    media_type: "image/png".into(),
1044                    data: ImageSource::Base64("...".into()),
1045                },
1046            ],
1047        };
1048        assert_eq!(msg.role, ChatRole::User);
1049        assert_eq!(msg.content.len(), 2);
1050    }
1051
1052    #[test]
1053    fn test_assistant_with_reasoning_is_valid_combination() {
1054        // Assistants can produce reasoning + text — valid state
1055        let msg = ChatMessage {
1056            role: ChatRole::Assistant,
1057            content: vec![
1058                ContentBlock::Reasoning {
1059                    content: "step 1: think about it".into(),
1060                },
1061                ContentBlock::Text("The answer is 42.".into()),
1062            ],
1063        };
1064        assert_eq!(msg.role, ChatRole::Assistant);
1065        assert_eq!(msg.content.len(), 2);
1066    }
1067
1068    // --- ChatMessage serialization stability tests ---
1069
1070    #[test]
1071    fn test_chat_message_to_json() {
1072        let msg = ChatMessage::user("Hello, world!");
1073        let json = msg.to_json().unwrap();
1074        assert_eq!(json["role"], "User");
1075        assert_eq!(json["content"][0]["text"], "Hello, world!");
1076    }
1077
1078    #[test]
1079    fn test_chat_message_from_json() {
1080        let json = serde_json::json!({
1081            "role": "Assistant",
1082            "content": [{"text": "Hello!"}]
1083        });
1084        let msg = ChatMessage::from_json(&json).unwrap();
1085        assert_eq!(msg.role, ChatRole::Assistant);
1086        assert!(matches!(&msg.content[0], ContentBlock::Text(t) if t == "Hello!"));
1087    }
1088
1089    #[test]
1090    fn test_chat_message_json_roundtrip() {
1091        let original = ChatMessage {
1092            role: ChatRole::User,
1093            content: vec![
1094                ContentBlock::Text("What's this?".into()),
1095                ContentBlock::Image {
1096                    media_type: "image/png".into(),
1097                    data: ImageSource::Base64("abc123".into()),
1098                },
1099            ],
1100        };
1101        let json = original.to_json().unwrap();
1102        let restored = ChatMessage::from_json(&json).unwrap();
1103        assert_eq!(original, restored);
1104    }
1105
1106    #[test]
1107    fn test_chat_message_json_roundtrip_with_tool_result() {
1108        let original = ChatMessage::tool_result("tc_1", "success");
1109        let json = original.to_json().unwrap();
1110        let restored = ChatMessage::from_json(&json).unwrap();
1111        assert_eq!(original, restored);
1112    }
1113
1114    // --- ToolResult metadata tests ---
1115}