Skip to main content

supercode/
message.rs

1use serde::{Deserialize, Serialize};
2
3/// Who authored a [`ChatMessage`].
4///
5/// Serializes to the lowercase strings the OpenAI chat-completions API expects
6/// (`system`, `user`, `assistant`, `tool`).
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum Role {
10    /// Operator / system instructions.
11    System,
12    /// End-user input.
13    User,
14    /// Model output.
15    Assistant,
16    /// The result of a tool call, fed back to the model.
17    Tool,
18}
19
20/// A single message in a conversation.
21///
22/// The field layout mirrors the OpenAI chat-completions wire format so it
23/// serializes directly with no conversion layer — which is exactly what
24/// OpenRouter and other OpenAI-compatible gateways consume.
25#[derive(Debug, Clone, PartialEq)]
26pub struct ChatMessage {
27    /// Author of the message.
28    pub role: Role,
29
30    /// Text content. `None` for assistant turns that are pure tool calls, or
31    /// when [`Self::content_parts`] carries multimodal content instead.
32    pub content: Option<String>,
33
34    /// Multimodal content parts (e.g. `{"type":"image_url", ...}` alongside
35    /// `{"type":"text", ...}`). When present, these are serialized as the wire
36    /// `content` array (taking precedence over [`Self::content`]) — this is how
37    /// images/vision input reach a vision model.
38    pub content_parts: Option<Vec<serde_json::Value>>,
39
40    /// Tool calls requested by an assistant turn.
41    pub tool_calls: Option<Vec<ToolCall>>,
42
43    /// For `tool` messages: the id of the [`ToolCall`] this is a result for.
44    pub tool_call_id: Option<String>,
45
46    /// Optional name (used by some providers for tool messages).
47    pub name: Option<String>,
48
49    /// Source-format provenance/labels that have no slot in the OpenAI wire
50    /// shape (e.g. Codex `phase`, `turn_id`; Claude `promptSource`, `isMeta`,
51    /// `sourceToolAssistantUUID`). Never serialized — kept only for fidelity and
52    /// inspection so loading a session doesn't silently discard this signal.
53    /// (Never serialized — the custom `Serialize` impl omits it.)
54    pub metadata: std::collections::BTreeMap<String, String>,
55}
56
57impl serde::Serialize for ChatMessage {
58    fn serialize<S: serde::Serializer>(&self, ser: S) -> std::result::Result<S::Ok, S::Error> {
59        use serde::ser::SerializeMap;
60        let mut m = ser.serialize_map(None)?;
61        m.serialize_entry("role", &self.role)?;
62        // Multimodal parts take precedence and serialize as the `content` array.
63        if let Some(parts) = &self.content_parts {
64            m.serialize_entry("content", parts)?;
65        } else if let Some(c) = &self.content {
66            m.serialize_entry("content", c)?;
67        }
68        if let Some(tc) = &self.tool_calls {
69            m.serialize_entry("tool_calls", tc)?;
70        }
71        if let Some(id) = &self.tool_call_id {
72            m.serialize_entry("tool_call_id", id)?;
73        }
74        if let Some(n) = &self.name {
75            m.serialize_entry("name", n)?;
76        }
77        m.end()
78    }
79}
80
81impl<'de> serde::Deserialize<'de> for ChatMessage {
82    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> std::result::Result<Self, D::Error> {
83        #[derive(Deserialize)]
84        struct Raw {
85            role: Role,
86            #[serde(default)]
87            content: Option<serde_json::Value>,
88            #[serde(default)]
89            tool_calls: Option<Vec<ToolCall>>,
90            #[serde(default)]
91            tool_call_id: Option<String>,
92            #[serde(default)]
93            name: Option<String>,
94        }
95        let raw = Raw::deserialize(de)?;
96        // `content` may be a string or a multimodal array.
97        let (content, content_parts) = match raw.content {
98            Some(serde_json::Value::String(s)) => (Some(s), None),
99            Some(serde_json::Value::Array(a)) => (None, Some(a)),
100            Some(serde_json::Value::Null) | None => (None, None),
101            Some(other) => (Some(other.to_string()), None),
102        };
103        Ok(ChatMessage {
104            role: raw.role,
105            content,
106            content_parts,
107            tool_calls: raw.tool_calls,
108            tool_call_id: raw.tool_call_id,
109            name: raw.name,
110            metadata: Default::default(),
111        })
112    }
113}
114
115impl ChatMessage {
116    /// Build a `system` message.
117    pub fn system(content: impl Into<String>) -> Self {
118        Self::text(Role::System, content)
119    }
120
121    /// Build a `user` message with multimodal content — leading text plus one
122    /// `image_url` part per URL (an `https://…` link or a `data:` URL). This is
123    /// how images are passed to a vision model.
124    pub fn user_with_images(text: impl Into<String>, image_urls: &[String]) -> Self {
125        let mut parts = vec![serde_json::json!({"type": "text", "text": text.into()})];
126        for url in image_urls {
127            parts.push(serde_json::json!({"type": "image_url", "image_url": {"url": url}}));
128        }
129        ChatMessage {
130            role: Role::User,
131            content: None,
132            content_parts: Some(parts),
133            tool_calls: None,
134            tool_call_id: None,
135            name: None,
136            metadata: Default::default(),
137        }
138    }
139
140    /// Build a `user` message.
141    pub fn user(content: impl Into<String>) -> Self {
142        Self::text(Role::User, content)
143    }
144
145    /// Build an `assistant` message with plain text.
146    pub fn assistant(content: impl Into<String>) -> Self {
147        Self::text(Role::Assistant, content)
148    }
149
150    /// Build a `tool` result message tied to a specific tool call.
151    pub fn tool_result(
152        tool_call_id: impl Into<String>,
153        name: impl Into<String>,
154        content: impl Into<String>,
155    ) -> Self {
156        ChatMessage {
157            role: Role::Tool,
158            content: Some(content.into()),
159            content_parts: None,
160            tool_calls: None,
161            tool_call_id: Some(tool_call_id.into()),
162            name: Some(name.into()),
163            metadata: Default::default(),
164        }
165    }
166
167    /// P4c (COMPOSABLE-HARNESS-DESIGN.md §1.2 `core.tools.read_file
168    /// multimodal` / `view_image`): a `tool` result that carries an image
169    /// content block alongside a short text notice — `content_parts`
170    /// (image passthrough) rather than a plain string, so the model
171    /// actually sees the image. `data_url` is a full `data:image/...;
172    /// base64,...` URL (see `tools::builtins::image_tool_result`).
173    pub fn tool_result_with_image(
174        tool_call_id: impl Into<String>,
175        name: impl Into<String>,
176        notice: impl Into<String>,
177        data_url: impl Into<String>,
178    ) -> Self {
179        ChatMessage {
180            role: Role::Tool,
181            content: None,
182            content_parts: Some(vec![
183                serde_json::json!({"type": "text", "text": notice.into()}),
184                serde_json::json!({"type": "image_url", "image_url": {"url": data_url.into()}}),
185            ]),
186            tool_calls: None,
187            tool_call_id: Some(tool_call_id.into()),
188            name: Some(name.into()),
189            metadata: Default::default(),
190        }
191    }
192
193    fn text(role: Role, content: impl Into<String>) -> Self {
194        ChatMessage {
195            role,
196            content: Some(content.into()),
197            content_parts: None,
198            tool_calls: None,
199            tool_call_id: None,
200            name: None,
201            metadata: Default::default(),
202        }
203    }
204
205    /// The tool calls on this message, or an empty slice.
206    pub fn tool_calls(&self) -> &[ToolCall] {
207        self.tool_calls.as_deref().unwrap_or(&[])
208    }
209
210    /// Attach a metadata key/value, returning `self` (builder style).
211    pub fn with_meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
212        self.metadata.insert(key.into(), value.into());
213        self
214    }
215
216    /// Attach several metadata key/values, returning `self`.
217    pub fn with_metas(mut self, pairs: &[(String, String)]) -> Self {
218        for (k, v) in pairs {
219            self.metadata.insert(k.clone(), v.clone());
220        }
221        self
222    }
223}
224
225/// A request from the model to invoke a tool.
226#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
227pub struct ToolCall {
228    /// Provider-assigned id; the matching `tool` result must echo it.
229    pub id: String,
230
231    /// Always `"function"` in the OpenAI format.
232    #[serde(rename = "type", default = "default_tool_type")]
233    pub kind: String,
234
235    /// The function name + serialized arguments.
236    pub function: FunctionCall,
237}
238
239/// The function payload of a [`ToolCall`].
240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
241pub struct FunctionCall {
242    /// Tool name.
243    pub name: String,
244
245    /// Arguments as a JSON-encoded string (the wire format the API uses).
246    pub arguments: String,
247}
248
249impl FunctionCall {
250    /// Parse the JSON-encoded [`Self::arguments`] into a value.
251    ///
252    /// An empty or whitespace-only argument string is treated as `{}`.
253    pub fn parsed_arguments(&self) -> serde_json::Result<serde_json::Value> {
254        let trimmed = self.arguments.trim();
255        if trimmed.is_empty() {
256            return Ok(serde_json::Value::Object(Default::default()));
257        }
258        serde_json::from_str(trimmed)
259    }
260}
261
262fn default_tool_type() -> String {
263    "function".to_string()
264}