use std::{collections::BTreeMap, path::PathBuf, sync::Arc};
use derive_more::{Display, From};
use schemars::{JsonSchema, Schema};
use serde::{Deserialize, Serialize};
use serde_with::{DefaultOnError, VecSkipError, serde_as, skip_serializing_none};
use super::{ContentBlock, Meta};
use crate::{IntoMaybeUndefined, IntoOption, MaybeUndefined, SkipListener};
#[serde_as]
#[skip_serializing_none]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ToolCallUpdate {
pub tool_call_id: ToolCallId,
#[serde_as(deserialize_as = "DefaultOnError")]
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
pub title: MaybeUndefined<String>,
#[serde_as(deserialize_as = "DefaultOnError")]
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
pub kind: MaybeUndefined<ToolKind>,
#[serde_as(deserialize_as = "DefaultOnError")]
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
pub status: MaybeUndefined<ToolCallStatus>,
#[serde_as(deserialize_as = "DefaultOnError<MaybeUndefined<VecSkipError<_, SkipListener>>>")]
#[schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true))]
#[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
pub content: MaybeUndefined<Vec<ToolCallContent>>,
#[serde_as(deserialize_as = "DefaultOnError<MaybeUndefined<VecSkipError<_, SkipListener>>>")]
#[schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true))]
#[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
pub locations: MaybeUndefined<Vec<ToolCallLocation>>,
#[serde_as(deserialize_as = "DefaultOnError")]
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
pub raw_input: MaybeUndefined<serde_json::Value>,
#[serde_as(deserialize_as = "DefaultOnError")]
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
pub raw_output: MaybeUndefined<serde_json::Value>,
#[serde_as(deserialize_as = "DefaultOnError<MaybeUndefined<_>>")]
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[serde(
rename = "_meta",
default,
skip_serializing_if = "MaybeUndefined::is_undefined"
)]
pub meta: MaybeUndefined<Meta>,
}
impl ToolCallUpdate {
#[must_use]
pub fn new(tool_call_id: impl Into<ToolCallId>) -> Self {
Self {
tool_call_id: tool_call_id.into(),
title: MaybeUndefined::Undefined,
kind: MaybeUndefined::Undefined,
status: MaybeUndefined::Undefined,
content: MaybeUndefined::Undefined,
locations: MaybeUndefined::Undefined,
raw_input: MaybeUndefined::Undefined,
raw_output: MaybeUndefined::Undefined,
meta: MaybeUndefined::Undefined,
}
}
#[must_use]
pub fn title(mut self, title: impl IntoMaybeUndefined<String>) -> Self {
self.title = title.into_maybe_undefined();
self
}
#[must_use]
pub fn kind(mut self, kind: impl IntoMaybeUndefined<ToolKind>) -> Self {
self.kind = kind.into_maybe_undefined();
self
}
#[must_use]
pub fn status(mut self, status: impl IntoMaybeUndefined<ToolCallStatus>) -> Self {
self.status = status.into_maybe_undefined();
self
}
#[must_use]
pub fn content(mut self, content: impl IntoMaybeUndefined<Vec<ToolCallContent>>) -> Self {
self.content = content.into_maybe_undefined();
self
}
#[must_use]
pub fn locations(mut self, locations: impl IntoMaybeUndefined<Vec<ToolCallLocation>>) -> Self {
self.locations = locations.into_maybe_undefined();
self
}
#[must_use]
pub fn raw_input(mut self, raw_input: impl IntoMaybeUndefined<serde_json::Value>) -> Self {
self.raw_input = raw_input.into_maybe_undefined();
self
}
#[must_use]
pub fn raw_output(mut self, raw_output: impl IntoMaybeUndefined<serde_json::Value>) -> Self {
self.raw_output = raw_output.into_maybe_undefined();
self
}
#[must_use]
pub fn meta(mut self, meta: impl IntoMaybeUndefined<Meta>) -> Self {
self.meta = meta.into_maybe_undefined();
self
}
pub fn apply_update(&mut self, update: ToolCallUpdate) {
debug_assert_eq!(self.tool_call_id, update.tool_call_id);
if !update.title.is_undefined() {
self.title = update.title;
}
if !update.kind.is_undefined() {
self.kind = update.kind;
}
if !update.status.is_undefined() {
self.status = update.status;
}
if !update.content.is_undefined() {
self.content = update.content;
}
if !update.locations.is_undefined() {
self.locations = update.locations;
}
if !update.raw_input.is_undefined() {
self.raw_input = update.raw_input;
}
if !update.raw_output.is_undefined() {
self.raw_output = update.raw_output;
}
if !update.meta.is_undefined() {
self.meta = update.meta;
}
}
}
#[serde_as]
#[skip_serializing_none]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ToolCallContentChunk {
pub tool_call_id: ToolCallId,
pub content: ToolCallContent,
#[serde_as(deserialize_as = "DefaultOnError")]
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[serde(default)]
#[serde(rename = "_meta")]
pub meta: Option<Meta>,
}
impl ToolCallContentChunk {
#[must_use]
pub fn new(tool_call_id: impl Into<ToolCallId>, content: impl Into<ToolCallContent>) -> Self {
Self {
tool_call_id: tool_call_id.into(),
content: content.into(),
meta: None,
}
}
#[must_use]
pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
self.meta = meta.into_option();
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash, Display, From)]
#[serde(transparent)]
#[from(Arc<str>, String, &'static str)]
#[non_exhaustive]
pub struct ToolCallId(pub Arc<str>);
impl ToolCallId {
#[must_use]
pub fn new(id: impl Into<Arc<str>>) -> Self {
Self(id.into())
}
}
impl IntoOption<ToolCallId> for &str {
fn into_option(self) -> Option<ToolCallId> {
Some(ToolCallId::new(self))
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ToolKind {
Read,
Edit,
Delete,
Move,
Search,
Execute,
Think,
Fetch,
SwitchMode,
#[default]
Other,
#[serde(untagged)]
Unknown(String),
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ToolCallStatus {
#[default]
Pending,
InProgress,
Completed,
Failed,
#[serde(untagged)]
Other(String),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
#[schemars(extend("discriminator" = {"propertyName": "type"}))]
#[non_exhaustive]
pub enum ToolCallContent {
Content(Box<Content>),
Diff(Diff),
#[serde(untagged)]
Other(OtherToolCallContent),
}
#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
#[schemars(inline)]
#[schemars(transform = other_tool_call_content_schema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct OtherToolCallContent {
#[serde(rename = "type")]
pub type_: String,
#[serde(flatten)]
pub fields: BTreeMap<String, serde_json::Value>,
}
impl OtherToolCallContent {
#[must_use]
pub fn new(type_: impl Into<String>, mut fields: BTreeMap<String, serde_json::Value>) -> Self {
fields.remove("type");
Self {
type_: type_.into(),
fields,
}
}
}
impl<'de> Deserialize<'de> for OtherToolCallContent {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let mut fields = BTreeMap::<String, serde_json::Value>::deserialize(deserializer)?;
let type_ = fields
.remove("type")
.ok_or_else(|| serde::de::Error::missing_field("type"))?;
let serde_json::Value::String(type_) = type_ else {
return Err(serde::de::Error::custom("`type` must be a string"));
};
if is_known_tool_call_content_type(&type_) {
return Err(serde::de::Error::custom(format!(
"known tool call content `{type_}` did not match its schema"
)));
}
Ok(Self { type_, fields })
}
}
fn is_known_tool_call_content_type(type_: &str) -> bool {
matches!(type_, "content" | "diff")
}
fn other_tool_call_content_schema(schema: &mut Schema) {
super::schema_util::reject_known_string_discriminators(schema, "type", &["content", "diff"]);
}
impl<T: Into<ContentBlock>> From<T> for ToolCallContent {
fn from(content: T) -> Self {
ToolCallContent::Content(Box::new(Content::new(content)))
}
}
impl From<Diff> for ToolCallContent {
fn from(diff: Diff) -> Self {
ToolCallContent::Diff(diff)
}
}
#[serde_as]
#[skip_serializing_none]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Content {
pub content: ContentBlock,
#[serde_as(deserialize_as = "DefaultOnError")]
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[serde(default)]
#[serde(rename = "_meta")]
pub meta: Option<Meta>,
}
impl Content {
#[must_use]
pub fn new(content: impl Into<ContentBlock>) -> Self {
Self {
content: content.into(),
meta: None,
}
}
#[must_use]
pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
self.meta = meta.into_option();
self
}
}
#[serde_as]
#[skip_serializing_none]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Diff {
#[serde_as(deserialize_as = "VecSkipError<_, SkipListener>")]
#[schemars(extend("x-deserialize-skip-invalid-items" = true))]
pub changes: Vec<DiffChange>,
#[serde_as(deserialize_as = "DefaultOnError")]
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[serde(default)]
pub patch: Option<DiffPatch>,
#[serde_as(deserialize_as = "DefaultOnError")]
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[serde(default)]
#[serde(rename = "_meta")]
pub meta: Option<Meta>,
}
impl Diff {
#[must_use]
pub fn new(changes: Vec<DiffChange>) -> Self {
Self {
changes,
patch: None,
meta: None,
}
}
#[must_use]
pub fn patch(diff: impl Into<String>, changes: Vec<DiffChange>) -> Self {
Self::new(changes).with_patch(DiffPatch::new(diff))
}
#[must_use]
pub fn with_patch(mut self, patch: impl IntoOption<DiffPatch>) -> Self {
self.patch = patch.into_option();
self
}
#[must_use]
pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
self.meta = meta.into_option();
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct DiffPatch {
pub format: DiffPatchFormat,
pub diff: String,
}
impl DiffPatch {
#[must_use]
pub fn new(diff: impl Into<String>) -> Self {
Self {
format: DiffPatchFormat::GitPatch,
diff: diff.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum DiffPatchFormat {
GitPatch,
#[serde(untagged)]
Other(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum DiffFileType {
Text,
Binary,
Directory,
Symlink,
#[serde(untagged)]
Other(String),
}
#[serde_as]
#[skip_serializing_none]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct DiffChange {
#[serde_as(deserialize_as = "DefaultOnError")]
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[serde(default)]
pub file_type: Option<DiffFileType>,
#[serde_as(deserialize_as = "DefaultOnError")]
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[serde(default)]
pub mime_type: Option<String>,
#[serde(flatten)]
pub operation: DiffChangeOperation,
#[serde_as(deserialize_as = "DefaultOnError")]
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[serde(default)]
#[serde(rename = "_meta")]
pub meta: Option<Meta>,
}
impl DiffChange {
#[must_use]
pub fn new(operation: DiffChangeOperation) -> Self {
Self {
file_type: None,
mime_type: None,
operation,
meta: None,
}
}
#[must_use]
pub fn add(path: impl Into<PathBuf>) -> Self {
Self::new(DiffChangeOperation::Add(DiffPathChange::new(path)))
}
#[must_use]
pub fn delete(path: impl Into<PathBuf>) -> Self {
Self::new(DiffChangeOperation::Delete(DiffPathChange::new(path)))
}
#[must_use]
pub fn modify(path: impl Into<PathBuf>) -> Self {
Self::new(DiffChangeOperation::Modify(DiffPathChange::new(path)))
}
#[must_use]
pub fn move_file(old_path: impl Into<PathBuf>, path: impl Into<PathBuf>) -> Self {
Self::new(DiffChangeOperation::Move(DiffPathPairChange::new(
old_path, path,
)))
}
#[must_use]
pub fn copy(old_path: impl Into<PathBuf>, path: impl Into<PathBuf>) -> Self {
Self::new(DiffChangeOperation::Copy(DiffPathPairChange::new(
old_path, path,
)))
}
#[must_use]
pub fn file_type(mut self, file_type: impl IntoOption<DiffFileType>) -> Self {
self.file_type = file_type.into_option();
self
}
#[must_use]
pub fn mime_type(mut self, mime_type: impl IntoOption<String>) -> Self {
self.mime_type = mime_type.into_option();
self
}
#[must_use]
pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
self.meta = meta.into_option();
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "operation", rename_all = "snake_case")]
#[schemars(extend("discriminator" = {"propertyName": "operation"}))]
#[non_exhaustive]
pub enum DiffChangeOperation {
Add(DiffPathChange),
Delete(DiffPathChange),
Modify(DiffPathChange),
Move(DiffPathPairChange),
Copy(DiffPathPairChange),
#[serde(untagged)]
Other(OtherDiffChange),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct DiffPathChange {
pub path: PathBuf,
}
impl DiffPathChange {
#[must_use]
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct DiffPathPairChange {
pub old_path: PathBuf,
pub path: PathBuf,
}
impl DiffPathPairChange {
#[must_use]
pub fn new(old_path: impl Into<PathBuf>, path: impl Into<PathBuf>) -> Self {
Self {
old_path: old_path.into(),
path: path.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
#[schemars(inline)]
#[schemars(transform = other_diff_change_schema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct OtherDiffChange {
pub operation: String,
#[serde(flatten)]
pub fields: BTreeMap<String, serde_json::Value>,
}
impl OtherDiffChange {
#[must_use]
pub fn new(
operation: impl Into<String>,
mut fields: BTreeMap<String, serde_json::Value>,
) -> Self {
fields.remove("operation");
fields.remove("fileType");
fields.remove("mimeType");
fields.remove("_meta");
Self {
operation: operation.into(),
fields,
}
}
}
impl<'de> Deserialize<'de> for OtherDiffChange {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let mut fields = BTreeMap::<String, serde_json::Value>::deserialize(deserializer)?;
let operation = fields
.remove("operation")
.ok_or_else(|| serde::de::Error::missing_field("operation"))?;
let serde_json::Value::String(operation) = operation else {
return Err(serde::de::Error::custom("`operation` must be a string"));
};
if is_known_diff_change_operation(&operation) {
return Err(serde::de::Error::custom(format!(
"known diff change operation `{operation}` did not match its schema"
)));
}
fields.remove("fileType");
fields.remove("mimeType");
fields.remove("_meta");
Ok(Self { operation, fields })
}
}
fn is_known_diff_change_operation(operation: &str) -> bool {
matches!(operation, "add" | "delete" | "modify" | "move" | "copy")
}
fn other_diff_change_schema(schema: &mut Schema) {
super::schema_util::reject_known_string_discriminators(
schema,
"operation",
&["add", "delete", "modify", "move", "copy"],
);
}
#[serde_as]
#[skip_serializing_none]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ToolCallLocation {
pub path: PathBuf,
#[serde_as(deserialize_as = "DefaultOnError")]
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[serde(default)]
pub line: Option<u32>,
#[serde_as(deserialize_as = "DefaultOnError")]
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[serde(default)]
#[serde(rename = "_meta")]
pub meta: Option<Meta>,
}
impl ToolCallLocation {
#[must_use]
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
line: None,
meta: None,
}
}
#[must_use]
pub fn line(mut self, line: impl IntoOption<u32>) -> Self {
self.line = line.into_option();
self
}
#[must_use]
pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
self.meta = meta.into_option();
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::MaybeUndefined;
#[test]
fn tool_call_serializes_as_upsert() {
let tool_call = ToolCallUpdate::new("tc_1")
.title("Reading configuration")
.status(ToolCallStatus::InProgress)
.raw_input(serde_json::json!({"path": "settings.json"}));
assert_eq!(
serde_json::to_value(tool_call).unwrap(),
serde_json::json!({
"toolCallId": "tc_1",
"title": "Reading configuration",
"status": "in_progress",
"rawInput": {
"path": "settings.json"
}
})
);
}
#[test]
fn tool_call_update_distinguishes_omitted_null_and_value() {
let tool_call = ToolCallUpdate::new("tc_1")
.status(ToolCallStatus::Completed)
.content(None::<Vec<ToolCallContent>>);
assert_eq!(
serde_json::to_value(tool_call).unwrap(),
serde_json::json!({
"toolCallId": "tc_1",
"status": "completed",
"content": null
})
);
let deserialized: ToolCallUpdate = serde_json::from_value(serde_json::json!({
"toolCallId": "tc_1",
"status": null,
"locations": []
}))
.unwrap();
assert_eq!(deserialized.title, MaybeUndefined::Undefined);
assert_eq!(deserialized.status, MaybeUndefined::Null);
assert_eq!(deserialized.locations, MaybeUndefined::Value(Vec::new()));
}
#[test]
fn tool_call_update_distinguishes_meta_omitted_null_and_value() {
let mut meta = Meta::new();
meta.insert("source".to_string(), serde_json::json!("tool-call"));
assert_eq!(
serde_json::to_value(ToolCallUpdate::new("tc_1").meta(meta.clone())).unwrap(),
serde_json::json!({
"toolCallId": "tc_1",
"_meta": {
"source": "tool-call"
}
})
);
assert_eq!(
serde_json::to_value(ToolCallUpdate::new("tc_1").meta(None::<Meta>)).unwrap(),
serde_json::json!({
"toolCallId": "tc_1",
"_meta": null
})
);
let deserialized: ToolCallUpdate = serde_json::from_value(serde_json::json!({
"toolCallId": "tc_1",
"_meta": null
}))
.unwrap();
assert_eq!(deserialized.meta, MaybeUndefined::Null);
let patch = ToolCallUpdate::new("tc_1");
assert_eq!(patch.meta, MaybeUndefined::Undefined);
let mut stored = ToolCallUpdate::new("tc_1").meta(meta);
stored.apply_update(ToolCallUpdate::new("tc_1").meta(None::<Meta>));
assert_eq!(stored.meta, MaybeUndefined::Null);
}
#[test]
fn tool_call_update_skips_malformed_list_items() {
let deserialized: ToolCallUpdate = serde_json::from_value(serde_json::json!({
"toolCallId": "tc_1",
"content": [
{
"type": "content",
"content": {
"type": "text",
"text": "ok"
}
},
{
"type": "diff",
"path": "/bad"
}
],
"locations": [
{
"path": "/ok",
"line": 3
},
{
"line": 4
}
]
}))
.unwrap();
let MaybeUndefined::Value(content) = deserialized.content else {
panic!("content should deserialize to a value");
};
assert_eq!(content.len(), 1);
let MaybeUndefined::Value(locations) = deserialized.locations else {
panic!("locations should deserialize to a value");
};
assert_eq!(locations.len(), 1);
}
#[test]
fn tool_call_content_chunk_serializes_single_content_item() {
let chunk = ToolCallContentChunk::new(
"tc_1",
ContentBlock::Text(crate::v2::TextContent::new("partial output")),
);
assert_eq!(
serde_json::to_value(chunk).unwrap(),
serde_json::json!({
"toolCallId": "tc_1",
"content": {
"type": "content",
"content": {
"type": "text",
"text": "partial output"
}
}
})
);
}
#[test]
fn diff_patch_serializes_git_patch_with_structured_changes() {
let diff = ToolCallContent::Diff(Diff::patch(
"diff --git /repo/config.json /repo/config.json\n",
vec![
DiffChange::modify("/repo/config.json")
.file_type(DiffFileType::Text)
.mime_type("application/json"),
],
));
assert_eq!(
serde_json::to_value(diff).unwrap(),
serde_json::json!({
"type": "diff",
"changes": [
{
"operation": "modify",
"path": "/repo/config.json",
"fileType": "text",
"mimeType": "application/json"
}
],
"patch": {
"format": "git_patch",
"diff": "diff --git /repo/config.json /repo/config.json\n"
}
})
);
}
#[test]
fn diff_serializes_binary_modify_without_patch_text() {
let diff = ToolCallContent::Diff(Diff::new(vec![
DiffChange::modify("/repo/assets/logo.png")
.file_type(DiffFileType::Binary)
.mime_type("image/png"),
]));
assert_eq!(
serde_json::to_value(diff).unwrap(),
serde_json::json!({
"type": "diff",
"changes": [
{
"operation": "modify",
"path": "/repo/assets/logo.png",
"fileType": "binary",
"mimeType": "image/png"
}
]
})
);
}
#[test]
fn diff_move_serializes_shared_fields_with_operation_payload() {
let diff = ToolCallContent::Diff(Diff::new(vec![
DiffChange::move_file("/repo/src/old.rs", "/repo/src/new.rs")
.file_type(DiffFileType::Text)
.mime_type("text/rust"),
]));
assert_eq!(
serde_json::to_value(diff).unwrap(),
serde_json::json!({
"type": "diff",
"changes": [
{
"operation": "move",
"oldPath": "/repo/src/old.rs",
"path": "/repo/src/new.rs",
"fileType": "text",
"mimeType": "text/rust"
}
]
})
);
}
#[test]
fn diff_changes_skip_malformed_list_items() {
let content: ToolCallContent = serde_json::from_value(serde_json::json!({
"type": "diff",
"changes": [
{
"operation": "modify"
},
{
"operation": "delete",
"path": "/ok"
}
],
"patch": {
"format": "git_patch",
"diff": "diff --git /ok /ok\n"
}
}))
.unwrap();
let ToolCallContent::Diff(diff) = content else {
panic!("expected diff content");
};
assert_eq!(diff.changes, vec![DiffChange::delete("/ok")]);
assert_eq!(diff.patch, Some(DiffPatch::new("diff --git /ok /ok\n")));
}
#[test]
fn tool_kind_preserves_unknown_variant() {
let kind: ToolKind = serde_json::from_str("\"review\"").unwrap();
assert_eq!(kind, ToolKind::Unknown("review".to_string()));
assert_eq!(serde_json::to_value(&kind).unwrap(), "review");
}
#[test]
fn tool_call_status_preserves_unknown_variant() {
let status: ToolCallStatus = serde_json::from_str("\"deferred\"").unwrap();
assert_eq!(status, ToolCallStatus::Other("deferred".to_string()));
assert_eq!(serde_json::to_value(&status).unwrap(), "deferred");
}
#[test]
fn tool_call_content_preserves_unknown_variant() {
let content: ToolCallContent = serde_json::from_value(serde_json::json!({
"type": "_chart",
"title": "Tests",
"data": [1, 2, 3]
}))
.unwrap();
let ToolCallContent::Other(unknown) = content else {
panic!("expected unknown tool call content");
};
assert_eq!(unknown.type_, "_chart");
assert_eq!(
unknown.fields.get("title"),
Some(&serde_json::json!("Tests"))
);
assert_eq!(
serde_json::to_value(ToolCallContent::Other(unknown)).unwrap(),
serde_json::json!({
"type": "_chart",
"title": "Tests",
"data": [1, 2, 3]
})
);
}
#[test]
fn tool_call_content_does_not_hide_malformed_known_variant() {
assert!(
serde_json::from_value::<ToolCallContent>(serde_json::json!({
"type": "diff"
}))
.is_err()
);
}
}