Skip to main content

loopctl/
message.rs

1//! Message types for agent conversations.
2//!
3//! Core message representation used throughout the loopctl framework for communication between users, agents, and
4//! tools. Messages consist of a [`Role`] and a list of [`MessagePart`]s,
5//! supporting plain text, base64-encoded images, tool invocations, and
6//! tool results.
7//!
8//! Messages flow through the system in a conversation history — a
9//! `Vec<Message>` that is sent to the LLM API on each turn and appended
10//! to as the conversation progresses. The API produces assistant messages
11//! (potentially with tool-call parts), and the framework responds with
12//! user-role messages containing tool results.
13//!
14//! # Provided Types
15//!
16//! - **[`Message`]** — A single message in a conversation, with a [`Role`]
17//!   and zero or more [`MessagePart`]s.
18//! - **[`Role`]** — Who sent the message (`User` or `Assistant`).
19//! - **[`MessagePart`]** — A polymorphic part (text, image, tool-call, or tool-result).
20//! - **[`ImageSource`]** — Base64-encoded image data in loopctl API format.
21//! - **[`ToolContent`]** — Content returned by a tool (string or multipart).
22//! - **[`ToolContentPart`]** — A single part of a multipart tool result.
23//!
24//! # Message Flow
25//!
26//! ```text
27//! User sends text ──▶ Message::user("query")
28//!                         │
29//!                     API processes
30//!                         │
31//!         ◀── Message::assistant("thinking...")
32//!         ◀── MessagePart::ToolCall { id, name, input }
33//!                         │
34//!              Framework executes tool
35//!                         │
36//!         ──▶ MessagePart::ToolResult { call_id, output }
37//!                         │
38//!                     API continues
39//!         ◀── Message::assistant("final answer")
40//! ```
41//!
42//! # Quick Start
43//!
44//! ```rust
45//! use loopctl::message::{Message, MessagePart, Role, ToolContent};
46//!
47//! // Create a simple user message
48//! let user_msg = Message::user("What files are in /tmp?");
49//!
50//! // Create an assistant message with a tool invocation
51//! let assistant_msg = Message {
52//!     role: Role::Assistant,
53//!     parts: vec![
54//!         MessagePart::text("Let me check that for you."),
55//!         MessagePart::tool_call("tool_1", "list_files", serde_json::json!({"path": "/tmp"})),
56//!     ],
57//! };
58//!
59//! // Create a tool-result message
60//! let tool_result_msg = Message {
61//!     role: Role::User, // tool results are sent back as "user" role
62//!     parts: vec![
63//!         MessagePart::tool_result("tool_1", ToolContent::from_string("file1.txt\nfile2.txt"), false),
64//!     ],
65//! };
66//! ```
67
68use serde::{Deserialize, Serialize};
69use serde_json::Value;
70use std::fmt;
71
72// ==================================================
73// Message
74// ==================================================
75
76/// A message in the conversation.
77///
78/// Messages are the fundamental unit of communication between users,
79/// agents, and tools. Each message has a [`Role`] and a list of
80/// [`MessagePart`]s. The `parts` is a `Vec<MessagePart>` because
81/// a single assistant response can contain both text and tool-call
82/// parts.
83///
84/// # Construction
85///
86/// Use the [`user`](Message::user) and [`assistant`](Message::assistant)
87/// convenience constructors for simple text messages, or [`new`](Message::new)
88/// for messages with multiple parts.
89///
90/// # Example
91///
92/// ```rust
93/// use loopctl::message::{Message, Role};
94/// let msg = Message::user("Hello, agent!");
95/// assert_eq!(msg.role, Role::User);
96/// assert_eq!(msg.parts.len(), 1);
97/// ```
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct Message {
100    /// Who sent this message.
101    ///
102    /// Determines whether the message came from the [`User`](Role::User)
103    /// or the [`Assistant`](Role::Assistant). Tool-result messages are
104    /// typically sent with [`Role::User`] per convention.
105    pub role: Role,
106
107    /// The parts in this message.
108    ///
109    /// A message can contain multiple parts of different types — for
110    /// example, an assistant response might include a [`Text`](MessagePart::Text)
111    /// part followed by a [`ToolCall`](MessagePart::ToolCall) block.
112    /// An empty vector is valid and represents a message with no content.
113    pub parts: Vec<MessagePart>,
114}
115
116impl Message {
117    /// Create a user message from plain text.
118    ///
119    /// Convenience constructor that wraps the text in a single
120    /// [`MessagePart::Text`] part with [`Role::User`].
121    ///
122    /// # Arguments
123    ///
124    /// - `text` — The message text. Accepts any type that implements
125    ///   `Into<String>` (e.g. `&str`, `String`).
126    ///
127    /// # Returns
128    ///
129    /// A [`Message`] with role [`Role::User`] and one text part.
130    ///
131    /// # Example
132    ///
133    /// ```rust
134    /// use loopctl::message::{Message, Role};
135    /// let msg = Message::user("What is the weather today?");
136    /// assert_eq!(msg.role, Role::User);
137    /// ```
138    pub fn user(text: impl Into<String>) -> Self {
139        Self {
140            role: Role::User,
141            parts: vec![MessagePart::text(text)],
142        }
143    }
144
145    /// Create an assistant message from plain text.
146    ///
147    /// Convenience constructor that wraps the text in a single
148    /// [`MessagePart::Text`] part with [`Role::Assistant`].
149    ///
150    /// # Arguments
151    ///
152    /// - `text` — The message text. Accepts any type that implements
153    ///   `Into<String>` (e.g. `&str`, `String`).
154    ///
155    /// # Returns
156    ///
157    /// A [`Message`] with role [`Role::Assistant`] and one text part.
158    ///
159    /// # Example
160    ///
161    /// ```rust
162    /// use loopctl::message::{Message, Role};
163    /// let msg = Message::assistant("The weather is sunny.");
164    /// assert_eq!(msg.role, Role::Assistant);
165    /// ```
166    pub fn assistant(text: impl Into<String>) -> Self {
167        Self {
168            role: Role::Assistant,
169            parts: vec![MessagePart::text(text)],
170        }
171    }
172
173    /// Create a message with the given role and parts.
174    ///
175    /// Use this constructor when you need multiple parts
176    /// (e.g. text + tool-call in the same message).
177    ///
178    /// # Arguments
179    ///
180    /// - `role` — The [`Role`] of the message sender.
181    /// - `parts` — A vector of [`MessagePart`]s.
182    ///
183    /// # Returns
184    ///
185    /// A [`Message`] with the specified role and content.
186    ///
187    /// # Example
188    ///
189    /// ```rust
190    /// use loopctl::message::{Message, MessagePart, Role};
191    /// let msg = Message::new(Role::Assistant, vec![
192    ///     MessagePart::text("Let me look that up."),
193    ///     MessagePart::tool_call("t1", "search", serde_json::json!({"q": "weather"})),
194    /// ]);
195    /// ```
196    #[must_use]
197    pub const fn new(role: Role, parts: Vec<MessagePart>) -> Self {
198        Self { role, parts }
199    }
200}
201
202/// Formats a [`Message`] for display.
203///
204/// Formats each [`MessagePart`] in the message on its own line.
205/// Text parts render as-is; tool-call parts render as
206/// `[Tool: {name} with input: {input}]`; tool-result parts render
207/// as `[Tool Result: {content}]`; image parts render as
208/// `[Image: {media_type}]`.
209///
210/// For structured serialization, use [`serde_json::to_string`] instead.
211///
212/// # Example
213///
214/// ```rust
215/// use loopctl::message::{Message};
216/// let msg = Message::user("Hello");
217/// println!("{msg}"); // prints "Hello"
218/// ```
219impl fmt::Display for Message {
220    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221        let mut chunks = Vec::new();
222        for part in &self.parts {
223            match part {
224                MessagePart::Text { text } => {
225                    chunks.push(text.clone());
226                }
227                MessagePart::ToolCall { name, input, .. } => {
228                    if let Ok(input_str) = serde_json::to_string(input) {
229                        chunks.push(format!("[Tool: {name} with input: {input_str}]"));
230                    }
231                }
232                MessagePart::ToolResult { output, .. } => {
233                    chunks.push(format!("[Tool Result: {output}]"));
234                }
235                MessagePart::Image { source } => {
236                    chunks.push(format!("[Image: {}]", source.media_type));
237                }
238            }
239        }
240        write!(f, "{}", chunks.join("\n"))
241    }
242}
243
244// ==================================================
245// Role
246// ==================================================
247
248/// The role of a message sender in the conversation.
249///
250/// Each [`Message`] has exactly one role that identifies who produced
251/// the content. Common LLM APIs use `"user"` for human messages and
252/// tool results, and `"assistant"` for model responses.
253///
254/// # Serialization
255///
256/// Serialized as lowercase `snake_case` strings (`"user"`, `"assistant"`)
257/// to match the API convention.
258///
259/// # Example
260///
261/// ```rust
262/// use loopctl::message::{Role};
263/// assert_eq!(Role::User.to_string(), "user");
264/// assert_eq!(Role::Assistant.to_string(), "assistant");
265/// ```
266#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
267#[serde(rename_all = "snake_case")]
268pub enum Role {
269    /// A message from the human user.
270    ///
271    /// Also used for tool-result messages sent back to the API,
272    /// per convention.
273    User,
274
275    /// A message from the assistant / LLM model.
276    ///
277    /// Contains the model's generated response, which may include
278    /// text, tool-call invocations, or both.
279    Assistant,
280}
281
282/// Formats a [`Role`] as its lowercase API string.
283///
284/// This implementation is used by [`Display`](fmt::Display) to produce
285/// the string that LLM APIs expect: `"user"` or `"assistant"`.
286/// It is also used for logging and building request payloads
287/// without pulling in the serde serializer.
288///
289/// # Example
290///
291/// ```rust
292/// use loopctl::message::{Role};
293/// assert_eq!(format!("Role: {}", Role::User), "Role: user");
294/// ```
295impl fmt::Display for Role {
296    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
297        match self {
298            Self::User => write!(f, "user"),
299            Self::Assistant => write!(f, "assistant"),
300        }
301    }
302}
303
304// ==================================================
305// MessagePart
306// ==================================================
307
308/// A part of message content within a [`Message`].
309///
310/// Messages can contain multiple types of content interleaved in a
311/// single response: plain text, base64-encoded images, tool-call
312/// invocations by the assistant, and tool results. Each variant
313/// corresponds to a possible part type with tagged JSON serialization.
314///
315/// # Serialization
316///
317/// Uses `#[serde(tag = "type")]` with renamed variants to produce
318/// JSON objects like `{"type": "text", "text": "..."}` matching the
319/// loopctl message schema.
320///
321/// # Construction
322///
323/// Use the constructors [`text`](MessagePart::text),
324/// [`tool_call`](MessagePart::tool_call), and
325/// [`tool_result`](MessagePart::tool_result) rather than building
326/// variants directly.
327///
328/// # Example
329///
330/// ```rust
331/// use loopctl::message::{MessagePart};
332/// let text_part = MessagePart::text("Hello");
333/// let tool_part = MessagePart::tool_call("id1", "search", serde_json::json!({"q": "test"}));
334/// let result_part = MessagePart::tool_result("id1", "found 3 results", false);
335/// ```
336#[derive(Debug, Clone, Serialize, Deserialize)]
337#[serde(tag = "type")]
338pub enum MessagePart {
339    /// Plain text content.
340    ///
341    /// Contains a UTF-8 text string generated by the model or
342    /// provided by the user.
343    #[serde(rename = "text")]
344    Text {
345        /// The text content of this part.
346        ///
347        /// May contain multiple lines. For streaming responses, this
348        /// is the fully assembled text after all streaming deltas have been concatenated.
349        text: String,
350    },
351
352    /// A base64-encoded image part.
353    ///
354    /// Images can be included in user messages for multimodal models.
355    /// The [`ImageSource`] contains the MIME type and base64 data.
356    ///
357    /// Serialized as `{"type":"image","source":{...}}`.
358    ///
359    /// Note: image parts are only valid in [`Role::User`] messages;
360    /// the API rejects them in assistant messages.
361    #[serde(rename = "image")]
362    Image {
363        /// The image data and metadata.
364        ///
365        /// Contains the MIME type, encoding type, and base64-encoded
366        /// image bytes. See [`ImageSource`] for details.
367        source: ImageSource,
368    },
369
370    /// A tool-call invocation by the assistant.
371    ///
372    /// When the model decides to call a tool, it emits a part with
373    /// the tool's ID, name, and JSON input. The framework then
374    /// executes the tool and sends the result back as a
375    /// [`ToolResult`](Self::ToolResult) block.
376    ///
377    /// The correlation between a `ToolCall` and its `ToolResult` is
378    /// done via the [`id`](Self::ToolCall.id) field, which must be
379    /// unique within a single conversation turn.
380    #[serde(rename = "tool_call")]
381    ToolCall {
382        /// Unique identifier for this tool invocation.
383        ///
384        /// Assigned by the API. Used to correlate the tool-call part
385        /// with the corresponding [`ToolResult`](Self::ToolResult)
386        /// part in the next message.
387        id: String,
388
389        /// The name of the tool being invoked.
390        ///
391        /// Must match one of the tool names provided in the request's
392        /// `tools` parameter.
393        name: String,
394
395        /// The JSON input provided by the model for the tool.
396        ///
397        /// Structure depends on the tool's input schema. May be
398        /// any JSON value (`Object`, `Array`, `String`, etc.).
399        input: Value,
400    },
401
402    /// The result of a tool-call invocation.
403    ///
404    /// Sent back to the model with [`Role::User`] (per API convention)
405    /// to provide the output of a tool execution. Contains the tool-call
406    /// ID for correlation, the output content, and an optional error flag.
407    ///
408    /// After the agent executes a tool, it wraps the output in this
409    /// variant and appends it to the conversation history so the model
410    /// can reason about the output.
411    #[serde(rename = "tool_result")]
412    ToolResult {
413        /// The ID of the tool-call part this result corresponds to.
414        ///
415        /// Must match the `id` from the original
416        /// [`ToolCall`](MessagePart::ToolCall) block.
417        call_id: String,
418
419        /// The output returned by the tool.
420        ///
421        /// Can be a simple string or a multipart response with
422        /// multiple parts. See [`ToolContent`].
423        output: ToolContent,
424
425        /// Whether this result represents an error.
426        ///
427        /// `Some(true)` indicates the tool invocation failed. When
428        /// `None` or `Some(false)`, the result is a success.
429        is_error: Option<bool>,
430    },
431}
432
433impl MessagePart {
434    /// Create a text part.
435    ///
436    /// Constructor for the [`Text`](MessagePart::Text) variant.
437    /// Accepts any type that implements `Into<String>`.
438    ///
439    /// # Arguments
440    ///
441    /// - `text` — The text content.
442    ///
443    /// # Returns
444    ///
445    /// A [`MessagePart::Text`] variant.
446    ///
447    /// # Example
448    ///
449    /// ```rust
450    /// use loopctl::message::{MessagePart};
451    /// let part = MessagePart::text("Hello, world!");
452    /// assert!(part.is_text());
453    /// ```
454    pub fn text(text: impl Into<String>) -> Self {
455        Self::Text { text: text.into() }
456    }
457
458    /// Create a tool-call part.
459    ///
460    /// Used when constructing an assistant message that invokes a tool.
461    /// The `id` must be unique within the conversation to allow
462    /// correlation with the corresponding tool result.
463    ///
464    /// # Arguments
465    ///
466    /// - `id` — Unique identifier for this tool invocation.
467    /// - `name` — The name of the tool to invoke.
468    /// - `input` — The JSON input parameters for the tool.
469    ///
470    /// # Returns
471    ///
472    /// A [`MessagePart::ToolCall`] variant.
473    ///
474    /// # Example
475    ///
476    /// ```rust
477    /// use loopctl::message::{MessagePart};
478    /// let part = MessagePart::tool_call(
479    ///     "tool_abc",
480    ///     "read_file",
481    ///     serde_json::json!({"path": "/tmp/test.txt"}),
482    /// );
483    /// assert!(part.is_tool_call());
484    /// ```
485    pub fn tool_call(id: impl Into<String>, name: impl Into<String>, input: Value) -> Self {
486        Self::ToolCall {
487            id: id.into(),
488            name: name.into(),
489            input,
490        }
491    }
492
493    /// Create a tool-result part.
494    ///
495    /// Used when sending the output of a tool execution back to the
496    /// model. The `call_id` must match the `id` from the original
497    /// [`ToolCall`](MessagePart::ToolCall) block.
498    ///
499    /// # Arguments
500    ///
501    /// - `call_id` — The ID of the tool invocation this output is for.
502    /// - `output` — The tool output (string or multipart).
503    /// - `is_error` — Whether the tool invocation failed.
504    ///
505    /// # Returns
506    ///
507    /// A [`MessagePart::ToolResult`] variant.
508    ///
509    /// # Example
510    ///
511    /// ```rust
512    /// use loopctl::message::{MessagePart};
513    /// let part = MessagePart::tool_result(
514    ///     "tool_abc",
515    ///     "file contents here",
516    ///     false,
517    /// );
518    /// assert!(part.is_tool_result());
519    /// ```
520    pub fn tool_result(
521        call_id: impl Into<String>,
522        output: impl Into<ToolContent>,
523        is_error: bool,
524    ) -> Self {
525        Self::ToolResult {
526            call_id: call_id.into(),
527            output: output.into(),
528            is_error: Some(is_error),
529        }
530    }
531
532    /// Returns `true` if this is a [`Text`](MessagePart::Text) block.
533    ///
534    /// Allows filtering or pattern matching on parts
535    /// without a full `match` expression.
536    ///
537    /// # Example
538    ///
539    /// ```rust
540    /// use loopctl::message::{MessagePart};
541    /// let part = MessagePart::text("hello");
542    /// assert!(part.is_text());
543    /// ```
544    #[must_use]
545    pub const fn is_text(&self) -> bool {
546        matches!(self, Self::Text { .. })
547    }
548
549    /// Returns `true` if this is a [`ToolCall`](MessagePart::ToolCall) block.
550    ///
551    /// Detects tool invocations in an assistant message
552    /// to decide whether to enter the tool-execution loop.
553    ///
554    /// # Example
555    ///
556    /// ```rust
557    /// use loopctl::message::{MessagePart};
558    /// use serde_json::Value;
559    /// let part = MessagePart::tool_call("id", "tool", Value::Null);
560    /// assert!(part.is_tool_call());
561    /// ```
562    #[must_use]
563    pub const fn is_tool_call(&self) -> bool {
564        matches!(self, Self::ToolCall { .. })
565    }
566
567    /// Returns `true` if this is a [`ToolResult`](MessagePart::ToolResult) block.
568    ///
569    /// Identifies tool results in a conversation history.
570    ///
571    /// # Example
572    ///
573    /// ```rust
574    /// use loopctl::message::{MessagePart};
575    /// let part = MessagePart::tool_result("id", "output", false);
576    /// assert!(part.is_tool_result());
577    /// ```
578    #[must_use]
579    pub const fn is_tool_result(&self) -> bool {
580        matches!(self, Self::ToolResult { .. })
581    }
582
583    /// Get the text content if this is a [`Text`](MessagePart::Text) block.
584    ///
585    /// Returns `Some(&str)` for text parts, `None` for all other
586    /// variants. Extracts the text from a known-text part.
587    ///
588    /// # Returns
589    ///
590    /// `Some(text)` if this is a text part, `None` otherwise.
591    ///
592    /// # Example
593    ///
594    /// ```rust
595    /// use loopctl::message::{MessagePart};
596    /// use serde_json::Value;
597    /// let part = MessagePart::text("hello");
598    /// assert_eq!(part.as_text(), Some("hello"));
599    ///
600    /// let tool = MessagePart::tool_call("id", "tool", Value::Null);
601    /// assert_eq!(tool.as_text(), None);
602    /// ```
603    #[must_use]
604    pub fn as_text(&self) -> Option<&str> {
605        match self {
606            Self::Text { text } => Some(text),
607            _ => None,
608        }
609    }
610}
611
612// ==================================================
613// ImageSource
614// ==================================================
615
616/// Source data for an image part in loopctl API format.
617///
618/// Represents a base64-encoded image with its MIME type. Serialized
619/// as:
620///
621/// ```json
622/// { "type": "base64", "media_type": "image/png", "data": "..." }
623/// ```
624///
625/// Use [`new_base64`](ImageSource::new_base64) to construct from
626/// a MIME type and raw base64 data.
627///
628/// # Construction
629///
630/// Always use [`new_base64`](ImageSource::new_base64) rather than
631/// building the struct directly — it ensures the `encoding` field
632/// is set correctly.
633///
634/// # Example
635///
636/// ```rust
637/// use loopctl::message::{ImageSource};
638/// let source = ImageSource::new_base64("image/png", "iVBORw0KGgo...");
639/// assert_eq!(source.media_type, "image/png");
640/// ```
641#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
642pub struct ImageSource {
643    /// The encoding type for the image data.
644    ///
645    /// LLM APIs always use `"base64"`.
646    pub encoding: String,
647
648    /// MIME type of the image (e.g. `"image/png"`, `"image/jpeg"`, `"image/webp"`).
649    ///
650    /// Must be one of the supported types for the model being used.
651    /// Used by the API to correctly decode the base64 data.
652    pub media_type: String,
653
654    /// Base64-encoded image data.
655    ///
656    /// The raw bytes of the image encoded as a base64 string. Should
657    /// not include a data-URI prefix — only the base64 payload.
658    pub data: String,
659}
660
661impl ImageSource {
662    /// Create a new base64 image source.
663    ///
664    /// Sets the `encoding` to `"base64"` automatically.
665    /// Standard construction method for image sources in loopctl API format.
666    ///
667    /// # Arguments
668    ///
669    /// - `media_type` — The MIME type (e.g. `"image/png"`).
670    /// - `data` — The base64-encoded image data.
671    ///
672    /// # Returns
673    ///
674    /// An [`ImageSource`] ready to use in an [`Image`](MessagePart::Image) block.
675    ///
676    /// # Example
677    ///
678    /// ```rust
679    /// use loopctl::message::{ImageSource};
680    /// let source = ImageSource::new_base64("image/png", "iVBORw0KGgo...");
681    /// assert_eq!(source.encoding, "base64");
682    /// ```
683    #[must_use]
684    pub fn new_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
685        Self {
686            encoding: "base64".into(),
687            media_type: media_type.into(),
688            data: data.into(),
689        }
690    }
691}
692
693// ==================================================
694// ToolContent
695// ==================================================
696
697/// Content that can be returned from a tool invocation.
698///
699/// Supports both simple string results and multipart results with
700/// multiple parts (text and images).
701///
702/// Serialized using `#[serde(untagged)]` so that a plain string
703/// is represented directly in JSON, while a multipart result
704/// is serialized as an array of [`ToolContentPart`] objects.
705///
706/// # Construction
707///
708/// Use [`from_string`](ToolContent::from_string) for simple
709/// text results or [`from_multipart`](ToolContent::from_multipart)
710/// for multi-part results. Both `String` and `&str` implement
711/// `Into<ToolContent>` for ergonomic conversion.
712///
713/// # Default
714///
715/// The [`Default`] implementation produces an empty string result,
716/// equivalent to `ToolContent::from_string("")`.
717///
718/// # Example
719///
720/// ```rust
721/// use loopctl::message::{ToolContent, ToolContentPart};
722/// // Simple string result
723/// let simple = ToolContent::from_string("File found: test.txt");
724///
725/// // Multipart result with multiple parts
726/// let multipart = ToolContent::from_multipart(vec![
727///     ToolContentPart::text("Line 1"),
728///     ToolContentPart::text("Line 2"),
729/// ]);
730/// ```
731#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
732#[serde(untagged)]
733pub enum ToolContent {
734    /// A simple string result.
735    ///
736    /// Returned by tools that produce plain text output.
737    Text(String),
738
739    /// A multipart result with multiple parts.
740    ///
741    /// Used when a tool needs to return mixed content (text + images)
742    /// or multiple text segments.
743    Multipart(Vec<ToolContentPart>),
744}
745
746impl ToolContent {
747    /// Create a simple string result.
748    ///
749    /// Wraps the given text in the [`Text`](Self::Text) variant.
750    ///
751    /// # Arguments
752    ///
753    /// - `s` — The text content of the result.
754    ///
755    /// # Returns
756    ///
757    /// A [`ToolContent::Text`] variant.
758    ///
759    /// # Example
760    ///
761    /// ```rust
762    /// use loopctl::message::{ToolContent};
763    /// let content = ToolContent::from_string("Done!");
764    /// assert!(content.is_string());
765    /// ```
766    #[must_use]
767    pub fn from_string(s: impl Into<String>) -> Self {
768        Self::Text(s.into())
769    }
770
771    /// Create a multipart result with multiple parts.
772    ///
773    /// Use this when a tool needs to return more than plain text —
774    /// for example, text combined with images.
775    ///
776    /// # Arguments
777    ///
778    /// - `parts` — The [`ToolContentPart`]s that make up the result.
779    ///
780    /// # Returns
781    ///
782    /// A [`ToolContent::Multipart`] variant.
783    ///
784    /// # Example
785    ///
786    /// ```rust
787    /// use loopctl::message::{ToolContent, ToolContentPart};
788    /// let content = ToolContent::from_multipart(vec![
789    ///     ToolContentPart::text("file1.txt"),
790    ///     ToolContentPart::text("file2.txt"),
791    /// ]);
792    /// ```
793    #[must_use]
794    pub const fn from_multipart(parts: Vec<ToolContentPart>) -> Self {
795        Self::Multipart(parts)
796    }
797
798    /// Returns `true` if this is a simple string result.
799    ///
800    /// Branches on how to process or display the result.
801    ///
802    /// # Example
803    ///
804    /// ```rust
805    /// use loopctl::message::{ToolContent};
806    /// let content = ToolContent::from_string("ok");
807    /// assert!(content.is_string());
808    /// ```
809    #[must_use]
810    pub const fn is_string(&self) -> bool {
811        matches!(self, Self::Text(_))
812    }
813}
814
815/// Produces a [`Default`] value for [`ToolContent`].
816///
817/// Returns an empty [`String`](ToolContent::Text) variant,
818/// which is the neutral starting point for tool results. Used when
819/// building responses incrementally or initializing result storage.
820///
821/// # Example
822///
823/// ```rust
824/// use loopctl::message::{ToolContent};
825/// let content = ToolContent::default();
826/// assert!(content.is_string());
827/// assert_eq!(content.to_string(), "");
828/// ```
829impl Default for ToolContent {
830    fn default() -> Self {
831        Self::Text(String::new())
832    }
833}
834
835/// Converts a [`String`] into a [`ToolContent::Text`].
836///
837/// This blanket conversion allows passing owned strings directly
838/// into APIs that accept [`Into<ToolContent>`], for example
839/// [`MessagePart::tool_result`].
840///
841/// # Example
842///
843/// ```rust
844/// use loopctl::message::{ToolContent};
845/// let content: ToolContent = "File found".to_string().into();
846/// assert!(content.is_string());
847/// ```
848impl From<String> for ToolContent {
849    fn from(s: String) -> Self {
850        Self::Text(s)
851    }
852}
853
854/// Converts a `&str` into a [`ToolContent::Text`].
855///
856/// This conversion allocates a new [`String`] from the borrowed slice.
857/// It enables ergonomic usage with string literals:
858///
859/// # Example
860///
861/// ```rust
862/// use loopctl::message::{ToolContent};
863/// let content: ToolContent = "ok".into();
864/// assert!(content.is_string());
865/// ```
866impl From<&str> for ToolContent {
867    fn from(s: &str) -> Self {
868        Self::Text(s.to_owned())
869    }
870}
871
872/// Formats a [`ToolContent`] for display.
873///
874/// For the [`String`](ToolContent::Text) variant, writes the
875/// text directly. For the [`Multipart`](ToolContent::Multipart)
876/// variant, concatenates all [`ToolContentPart::Text`] parts with
877/// newlines, silently skipping any non-text parts (e.g. images).
878///
879/// For quick debugging and logging, use [`Display`](std::fmt::Display).
880/// For full structured serialization, use [`serde_json::to_string`].
881///
882/// # Example
883///
884/// ```rust
885/// use loopctl::message::{ToolContent, ToolContentPart};
886/// let content = ToolContent::from_string("Done!");
887/// assert_eq!(content.to_string(), "Done!");
888///
889/// let multipart = ToolContent::from_multipart(vec![
890///     ToolContentPart::text("line 1"),
891///     ToolContentPart::text("line 2"),
892/// ]);
893/// assert_eq!(multipart.to_string(), "line 1\nline 2");
894/// ```
895impl fmt::Display for ToolContent {
896    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
897        match self {
898            Self::Text(s) => write!(f, "{s}"),
899            Self::Multipart(parts) => {
900                let texts: Vec<&str> = parts
901                    .iter()
902                    .filter_map(|part| {
903                        if let ToolContentPart::Text { text } = part {
904                            Some(text.as_str())
905                        } else {
906                            None
907                        }
908                    })
909                    .collect();
910                write!(f, "{}", texts.join("\n"))
911            }
912        }
913    }
914}
915
916// ==================================================
917// ToolContentPart
918// ==================================================
919
920/// A part of a Multipart [`ToolContent`].
921///
922/// Represents a single piece of content within a multi-part tool result.
923/// Currently supports images (via [`ImageSource`]) and text.
924///
925/// # Construction
926///
927/// Use [`text`](ToolContentPart::text) or [`image`](ToolContentPart::image)
928/// constructors rather than building variants directly.
929///
930/// # Example
931///
932/// ```rust
933/// use loopctl::message::{ImageSource, ToolContent, ToolContentPart};
934/// let text_part = ToolContentPart::text("Screenshot analysis:");
935/// let img_part = ToolContentPart::image(ImageSource::new_base64("image/png", "..."));
936/// let content = ToolContent::from_multipart(vec![text_part, img_part]);
937/// ```
938#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
939#[serde(tag = "type", rename_all = "snake_case")]
940pub enum ToolContentPart {
941    /// An image part within a Multipart tool result.
942    ///
943    /// Contains the base64-encoded image data. Used by tools
944    /// that capture screenshots or generate images.
945    Image {
946        /// The image data and metadata.
947        ///
948        /// See [`ImageSource`] for the structure and supported formats.
949        /// The [`ImageSource::encoding`] will always be `"base64"`.
950        source: ImageSource,
951    },
952
953    /// A text part within a Multipart tool result.
954    ///
955    /// Contains a plain text string.
956    Text {
957        /// The text content of this part.
958        ///
959        /// May contain multiple lines. Joined with other text parts
960        /// by [`ToolContent`]'s [`Display`](fmt::Display) impl.
961        text: String,
962    },
963}
964
965impl ToolContentPart {
966    /// Create a text part.
967    ///
968    /// Constructor for the [`Text`](ToolContentPart::Text) variant.
969    /// Use to build individual text segments within a
970    /// [`ToolContent::Multipart`] result.
971    ///
972    /// # Arguments
973    ///
974    /// - `text` — The text content. Accepts any type that implements
975    ///   `Into<String>` (e.g. `&str`, `String`).
976    ///
977    /// # Returns
978    ///
979    /// A [`ToolContentPart::Text`] variant.
980    ///
981    /// # Example
982    ///
983    /// ```rust
984    /// use loopctl::message::{ToolContentPart};
985    /// let part = ToolContentPart::text("result: 42");
986    /// ```
987    pub fn text(text: impl Into<String>) -> Self {
988        Self::Text { text: text.into() }
989    }
990
991    /// Create an image part.
992    ///
993    /// Use when a tool result includes image data alongside text.
994    /// Combine with [`ToolContentPart::text`] parts via
995    /// [`ToolContent::from_multipart`] to produce a multi-part
996    /// result containing both text and images.
997    ///
998    /// # Arguments
999    ///
1000    /// - `source` — The [`ImageSource`] containing the image data.
1001    ///   Construct with [`ImageSource::new_base64`].
1002    ///
1003    /// # Returns
1004    ///
1005    /// A [`ToolContentPart::Image`] variant.
1006    ///
1007    /// # Example
1008    ///
1009    /// ```rust
1010    /// use loopctl::message::{ImageSource, ToolContentPart};
1011    /// let part = ToolContentPart::image(ImageSource::new_base64("image/png", "..."));
1012    /// ```
1013    #[must_use]
1014    pub const fn image(source: ImageSource) -> Self {
1015        Self::Image { source }
1016    }
1017}
1018
1019// ==================================================
1020// Tests
1021// ==================================================
1022
1023#[cfg(test)]
1024mod tests {
1025    use super::*;
1026
1027    #[test]
1028    fn test_message_user_shortcut() {
1029        let msg = Message::user("Hello");
1030        assert_eq!(msg.role, Role::User);
1031        assert_eq!(msg.parts.len(), 1);
1032        assert_eq!(msg.parts[0].as_text(), Some("Hello"));
1033    }
1034
1035    #[test]
1036    fn test_message_assistant_shortcut() {
1037        let msg = Message::assistant("Hi there!");
1038        assert_eq!(msg.role, Role::Assistant);
1039        assert_eq!(msg.parts.len(), 1);
1040    }
1041
1042    #[test]
1043    fn test_message_display() {
1044        let msg = Message::user("Hello world");
1045        assert_eq!(msg.to_string(), "Hello world");
1046    }
1047
1048    #[test]
1049    fn test_message_display_with_tool_call() {
1050        let msg = Message {
1051            role: Role::Assistant,
1052            parts: vec![MessagePart::tool_call(
1053                "id1",
1054                "read_file",
1055                serde_json::json!({"path": "/tmp/test.txt"}),
1056            )],
1057        };
1058        let display = msg.to_string();
1059        assert!(display.contains("Tool: read_file"));
1060    }
1061
1062    #[test]
1063    fn test_role_display() {
1064        assert_eq!(Role::User.to_string(), "user");
1065        assert_eq!(Role::Assistant.to_string(), "assistant");
1066    }
1067
1068    #[test]
1069    fn test_part_helpers() {
1070        let text = MessagePart::text("hello");
1071        assert!(text.is_text());
1072        assert!(!text.is_tool_call());
1073        assert_eq!(text.as_text(), Some("hello"));
1074
1075        let tool_call = MessagePart::tool_call("id", "tool", serde_json::json!({}));
1076        assert!(tool_call.is_tool_call());
1077        assert!(!tool_call.is_text());
1078        assert!(tool_call.as_text().is_none());
1079
1080        let tool_result = MessagePart::tool_result("id", "ok", false);
1081        assert!(tool_result.is_tool_result());
1082    }
1083
1084    #[test]
1085    fn test_image_source() {
1086        let src = ImageSource::new_base64("image/png", "iVBOR...");
1087        assert_eq!(src.encoding, "base64");
1088        assert_eq!(src.media_type, "image/png");
1089    }
1090
1091    #[test]
1092    fn test_tool_result_from_string() {
1093        let result: ToolContent = "hello".into();
1094        assert!(result.is_string());
1095        assert_eq!(result.to_string(), "hello");
1096    }
1097
1098    #[test]
1099    fn test_tool_result_default() {
1100        let result = ToolContent::default();
1101        assert!(result.is_string());
1102    }
1103
1104    #[test]
1105    fn test_tool_result_part_text() {
1106        let part = ToolContentPart::text("output");
1107        match &part {
1108            ToolContentPart::Text { text } => assert_eq!(text, "output"),
1109            ToolContentPart::Image { .. } => panic!("expected text part"),
1110        }
1111    }
1112
1113    #[test]
1114    fn test_message_serialization() {
1115        let msg = Message::user("test");
1116        let json = serde_json::to_string(&msg).unwrap();
1117        let deserialized: Message = serde_json::from_str(&json).unwrap();
1118        assert_eq!(msg.role, deserialized.role);
1119    }
1120
1121    #[test]
1122    fn test_part_serialization_roundtrip() {
1123        let parts = vec![
1124            MessagePart::text("hello"),
1125            MessagePart::tool_call("id1", "my_tool", serde_json::json!({"key": "value"})),
1126        ];
1127        let json = serde_json::to_string(&parts).unwrap();
1128        let back: Vec<MessagePart> = serde_json::from_str(&json).unwrap();
1129        assert_eq!(parts.len(), back.len());
1130    }
1131
1132    #[test]
1133    fn test_tool_result_multipart_display() {
1134        let result = ToolContent::from_multipart(vec![
1135            ToolContentPart::text("line 1"),
1136            ToolContentPart::text("line 2"),
1137        ]);
1138        assert_eq!(result.to_string(), "line 1\nline 2");
1139    }
1140}