Skip to main content

supercode_interchange/
message.rs

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