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