supercode-interchange 0.4.9

Canonical, provider-neutral session interchange primitives for Supercode
Documentation
//! Canonical, provider-neutral conversation messages.

use serde::{Deserialize, Serialize};

/// Who authored a [`ChatMessage`].
///
/// Serializes to the lowercase strings the OpenAI chat-completions API expects
/// (`system`, `user`, `assistant`, `tool`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
    /// Operator / system instructions.
    System,
    /// End-user input.
    User,
    /// Model output.
    Assistant,
    /// The result of a tool call, fed back to the model.
    Tool,
}

/// A single message in a conversation.
///
/// The field layout mirrors the OpenAI chat-completions wire format so it
/// serializes directly with no conversion layer — which is exactly what
/// OpenRouter and other OpenAI-compatible gateways consume.
#[derive(Debug, Clone, PartialEq)]
pub struct ChatMessage {
    /// Author of the message.
    pub role: Role,

    /// Text content. `None` for assistant turns that are pure tool calls, or
    /// when [`Self::content_parts`] carries multimodal content instead.
    pub content: Option<String>,

    /// Multimodal content parts (e.g. `{"type":"image_url", ...}` alongside
    /// `{"type":"text", ...}`). When present, these are serialized as the wire
    /// `content` array (taking precedence over [`Self::content`]) — this is how
    /// images/vision input reach a vision model.
    pub content_parts: Option<Vec<serde_json::Value>>,

    /// Tool calls requested by an assistant turn.
    pub tool_calls: Option<Vec<ToolCall>>,

    /// For `tool` messages: the id of the [`ToolCall`] this is a result for.
    pub tool_call_id: Option<String>,

    /// Optional name (used by some providers for tool messages).
    pub name: Option<String>,

    /// Source-format provenance/labels that have no slot in the OpenAI wire
    /// shape (e.g. Codex `phase`, `turn_id`; Claude `promptSource`, `isMeta`,
    /// `sourceToolAssistantUUID`). Never serialized — kept only for fidelity and
    /// inspection so loading a session doesn't silently discard this signal.
    /// (Never serialized — the custom `Serialize` impl omits it.)
    pub metadata: std::collections::BTreeMap<String, String>,
}

impl serde::Serialize for ChatMessage {
    fn serialize<S: serde::Serializer>(&self, ser: S) -> std::result::Result<S::Ok, S::Error> {
        use serde::ser::SerializeMap;
        let mut m = ser.serialize_map(None)?;
        m.serialize_entry("role", &self.role)?;
        // Multimodal parts take precedence and serialize as the `content` array.
        if let Some(parts) = &self.content_parts {
            m.serialize_entry("content", parts)?;
        } else if let Some(c) = &self.content {
            m.serialize_entry("content", c)?;
        }
        if let Some(tc) = &self.tool_calls {
            m.serialize_entry("tool_calls", tc)?;
        }
        if let Some(id) = &self.tool_call_id {
            m.serialize_entry("tool_call_id", id)?;
        }
        if let Some(n) = &self.name {
            m.serialize_entry("name", n)?;
        }
        m.end()
    }
}

impl<'de> serde::Deserialize<'de> for ChatMessage {
    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> std::result::Result<Self, D::Error> {
        #[derive(Deserialize)]
        struct Raw {
            role: Role,
            #[serde(default)]
            content: Option<serde_json::Value>,
            #[serde(default)]
            tool_calls: Option<Vec<ToolCall>>,
            #[serde(default)]
            tool_call_id: Option<String>,
            #[serde(default)]
            name: Option<String>,
        }
        let raw = Raw::deserialize(de)?;
        // `content` may be a string or a multimodal array.
        let (content, content_parts) = match raw.content {
            Some(serde_json::Value::String(s)) => (Some(s), None),
            Some(serde_json::Value::Array(a)) => (None, Some(a)),
            Some(serde_json::Value::Null) | None => (None, None),
            Some(other) => (Some(other.to_string()), None),
        };
        Ok(ChatMessage {
            role: raw.role,
            content,
            content_parts,
            tool_calls: raw.tool_calls,
            tool_call_id: raw.tool_call_id,
            name: raw.name,
            metadata: Default::default(),
        })
    }
}

impl ChatMessage {
    /// Build a `system` message.
    pub fn system(content: impl Into<String>) -> Self {
        Self::text(Role::System, content)
    }

    /// Build a `user` message with multimodal content — leading text plus one
    /// `image_url` part per URL (an `https://…` link or a `data:` URL). This is
    /// how images are passed to a vision model.
    pub fn user_with_images(text: impl Into<String>, image_urls: &[String]) -> Self {
        let mut parts = vec![serde_json::json!({"type": "text", "text": text.into()})];
        for url in image_urls {
            parts.push(serde_json::json!({"type": "image_url", "image_url": {"url": url}}));
        }
        ChatMessage {
            role: Role::User,
            content: None,
            content_parts: Some(parts),
            tool_calls: None,
            tool_call_id: None,
            name: None,
            metadata: Default::default(),
        }
    }

    /// Build a `user` message.
    pub fn user(content: impl Into<String>) -> Self {
        Self::text(Role::User, content)
    }

    /// Build an `assistant` message with plain text.
    pub fn assistant(content: impl Into<String>) -> Self {
        Self::text(Role::Assistant, content)
    }

    /// Build a `tool` result message tied to a specific tool call.
    pub fn tool_result(
        tool_call_id: impl Into<String>,
        name: impl Into<String>,
        content: impl Into<String>,
    ) -> Self {
        ChatMessage {
            role: Role::Tool,
            content: Some(content.into()),
            content_parts: None,
            tool_calls: None,
            tool_call_id: Some(tool_call_id.into()),
            name: Some(name.into()),
            metadata: Default::default(),
        }
    }

    /// P4c (COMPOSABLE-HARNESS-DESIGN.md §1.2 `core.tools.read_file
    /// multimodal` / `view_image`): a `tool` result that carries an image
    /// content block alongside a short text notice — `content_parts`
    /// (image passthrough) rather than a plain string, so the model
    /// actually sees the image. `data_url` is a full `data:image/...;
    /// base64,...` URL (see `tools::builtins::image_tool_result`).
    pub fn tool_result_with_image(
        tool_call_id: impl Into<String>,
        name: impl Into<String>,
        notice: impl Into<String>,
        data_url: impl Into<String>,
    ) -> Self {
        ChatMessage {
            role: Role::Tool,
            content: None,
            content_parts: Some(vec![
                serde_json::json!({"type": "text", "text": notice.into()}),
                serde_json::json!({"type": "image_url", "image_url": {"url": data_url.into()}}),
            ]),
            tool_calls: None,
            tool_call_id: Some(tool_call_id.into()),
            name: Some(name.into()),
            metadata: Default::default(),
        }
    }

    fn text(role: Role, content: impl Into<String>) -> Self {
        ChatMessage {
            role,
            content: Some(content.into()),
            content_parts: None,
            tool_calls: None,
            tool_call_id: None,
            name: None,
            metadata: Default::default(),
        }
    }

    /// The tool calls on this message, or an empty slice.
    pub fn tool_calls(&self) -> &[ToolCall] {
        self.tool_calls.as_deref().unwrap_or(&[])
    }

    /// Attach a metadata key/value, returning `self` (builder style).
    pub fn with_meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.metadata.insert(key.into(), value.into());
        self
    }

    /// Attach several metadata key/values, returning `self`.
    pub fn with_metas(mut self, pairs: &[(String, String)]) -> Self {
        for (k, v) in pairs {
            self.metadata.insert(k.clone(), v.clone());
        }
        self
    }
}

/// Canonical metadata key marking a tool result as a structured error.
pub const TOOL_ERROR_METADATA_KEY: &str = "sc.tool_error";

/// Canonical metadata key marking a tool result whose outcome is unknown.
pub const TOOL_OUTCOME_UNKNOWN_METADATA_KEY: &str = "sc.tool_outcome_unknown";

/// The structural outcome known for a tool-result message.
///
/// `KnownSuccess` remains the default because some native formats omit their
/// error flag on success. Importers for formats without a structured outcome
/// must explicitly stamp [`ToolOutcome::Unknown`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolOutcome {
    /// The harness supplied success semantics, or an absent error flag means
    /// success in that harness's native contract.
    KnownSuccess,
    /// The harness supplied a structured error signal.
    KnownError,
    /// The harness supplied a result but no structured outcome signal.
    Unknown,
}

/// Stamp a tool-result message as a structured error.
pub fn mark_tool_error(message: &mut ChatMessage) {
    message.metadata.remove(TOOL_OUTCOME_UNKNOWN_METADATA_KEY);
    message
        .metadata
        .insert(TOOL_ERROR_METADATA_KEY.to_string(), "true".to_string());
}

/// Stamp a tool-result message as having no structurally known outcome.
pub fn mark_tool_outcome_unknown(message: &mut ChatMessage) {
    message.metadata.remove(TOOL_ERROR_METADATA_KEY);
    message.metadata.insert(
        TOOL_OUTCOME_UNKNOWN_METADATA_KEY.to_string(),
        "true".to_string(),
    );
}

/// Whether a message carries the canonical structured-error marker.
pub fn is_tool_error(message: &ChatMessage) -> bool {
    message
        .metadata
        .get(TOOL_ERROR_METADATA_KEY)
        .map(String::as_str)
        == Some("true")
}

/// Return the canonical structural outcome for a tool-result message.
pub fn tool_outcome(message: &ChatMessage) -> ToolOutcome {
    if is_tool_error(message) {
        ToolOutcome::KnownError
    } else if message
        .metadata
        .get(TOOL_OUTCOME_UNKNOWN_METADATA_KEY)
        .map(String::as_str)
        == Some("true")
    {
        ToolOutcome::Unknown
    } else {
        ToolOutcome::KnownSuccess
    }
}

/// A request from the model to invoke a tool.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
    /// Provider-assigned id; the matching `tool` result must echo it.
    pub id: String,

    /// Always `"function"` in the OpenAI format.
    #[serde(rename = "type", default = "default_tool_type")]
    pub kind: String,

    /// The function name + serialized arguments.
    pub function: FunctionCall,
}

/// The function payload of a [`ToolCall`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FunctionCall {
    /// Tool name.
    pub name: String,

    /// Arguments as a JSON-encoded string (the wire format the API uses).
    pub arguments: String,
}

impl FunctionCall {
    /// Parse the JSON-encoded [`Self::arguments`] into a value.
    ///
    /// An empty or whitespace-only argument string is treated as `{}`.
    pub fn parsed_arguments(&self) -> serde_json::Result<serde_json::Value> {
        let trimmed = self.arguments.trim();
        if trimmed.is_empty() {
            return Ok(serde_json::Value::Object(Default::default()));
        }
        serde_json::from_str(trimmed)
    }
}

fn default_tool_type() -> String {
    "function".to_string()
}