Skip to main content

agent_client_protocol_schema/v2/
tool_call.rs

1//! Tool calls represent actions that language models request agents to perform.
2//!
3//! When an LLM determines it needs to interact with external systems—like reading files,
4//! running code, or fetching data—it generates tool calls that the agent executes on its behalf.
5//!
6/// See protocol docs: [Tool Calls](https://agentclientprotocol.com/protocol/tool-calls)
7use std::{collections::BTreeMap, sync::Arc};
8
9use derive_more::{Display, From};
10#[cfg(feature = "schemars")]
11use schemars::Schema;
12use serde::{Deserialize, Serialize};
13use serde_with::{DefaultOnError, VecSkipError, serde_as, skip_serializing_none};
14
15use super::{AbsolutePath, ContentBlock, MediaType, Meta, Terminal};
16use crate::{IntoMaybeUndefined, IntoOption, MaybeUndefined, SkipListener};
17
18/// Represents an upsert for a tool call that the language model has requested.
19///
20/// Tool calls are actions that the agent executes on behalf of the language model,
21/// such as reading files, executing code, or fetching data from external sources.
22///
23/// Only [`ToolCallUpdate::tool_call_id`] is required. Other fields have patch semantics:
24/// omitted fields leave the existing tool call value unchanged, `null` clears or
25/// unsets the value, and concrete values replace the previous value. For
26/// collection fields, concrete arrays replace the previous collection, and both
27/// `null` and `[]` clear the collection. When a client receives a tool call ID it
28/// has not seen before, omitted fields use client defaults.
29///
30/// See protocol docs: [Tool Calls](https://agentclientprotocol.com/protocol/tool-calls)
31#[serde_as]
32#[skip_serializing_none]
33#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36#[non_exhaustive]
37pub struct ToolCallUpdate {
38    /// Unique identifier for this tool call within the session.
39    pub tool_call_id: ToolCallId,
40    /// **UNSTABLE**
41    ///
42    /// This capability is not part of the spec yet, and may be removed or changed at any point.
43    ///
44    /// Programmatic name of the tool being invoked.
45    ///
46    /// This field is optional and has patch semantics. Omission means no
47    /// change, `null` clears the name, and a string replaces it. For a tool
48    /// call ID the client has not seen before, omission or `null` means that no
49    /// tool name is available.
50    #[cfg(feature = "unstable_tool_call_name")]
51    #[serde_as(deserialize_as = "DefaultOnError")]
52    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
53    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
54    pub name: MaybeUndefined<String>,
55    /// Human-readable title describing what the tool is doing.
56    #[serde_as(deserialize_as = "DefaultOnError")]
57    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
58    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
59    pub title: MaybeUndefined<String>,
60    /// The category of tool being invoked.
61    /// Helps clients choose appropriate icons and UI treatment.
62    #[serde_as(deserialize_as = "DefaultOnError")]
63    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
64    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
65    pub kind: MaybeUndefined<ToolKind>,
66    /// Current execution status of the tool call.
67    #[serde_as(deserialize_as = "DefaultOnError")]
68    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
69    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
70    pub status: MaybeUndefined<ToolCallStatus>,
71    /// Content produced by the tool call.
72    #[serde_as(deserialize_as = "DefaultOnError<MaybeUndefined<VecSkipError<_, SkipListener>>>")]
73    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true)))]
74    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
75    pub content: MaybeUndefined<Vec<ToolCallContent>>,
76    /// File locations affected by this tool call.
77    /// Enables "follow-along" features in clients.
78    #[serde_as(deserialize_as = "DefaultOnError<MaybeUndefined<VecSkipError<_, SkipListener>>>")]
79    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true)))]
80    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
81    pub locations: MaybeUndefined<Vec<ToolCallLocation>>,
82    /// Raw input parameters sent to the tool.
83    #[serde_as(deserialize_as = "DefaultOnError")]
84    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
85    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
86    pub raw_input: MaybeUndefined<serde_json::Value>,
87    /// Raw output returned by the tool.
88    #[serde_as(deserialize_as = "DefaultOnError")]
89    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
90    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
91    pub raw_output: MaybeUndefined<serde_json::Value>,
92    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
93    /// metadata to their interactions. Omitted means no metadata update; `null` is an
94    /// explicit clear signal. Implementations MUST NOT make assumptions about values at these keys.
95    ///
96    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
97    #[serde_as(deserialize_as = "DefaultOnError<MaybeUndefined<_>>")]
98    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
99    #[serde(
100        rename = "_meta",
101        default,
102        skip_serializing_if = "MaybeUndefined::is_undefined"
103    )]
104    pub meta: MaybeUndefined<Meta>,
105}
106
107impl ToolCallUpdate {
108    /// Builds [`ToolCallUpdate`] with the required fields set; optional fields start unset or empty.
109    #[must_use]
110    pub fn new(tool_call_id: impl Into<ToolCallId>) -> Self {
111        Self {
112            tool_call_id: tool_call_id.into(),
113            #[cfg(feature = "unstable_tool_call_name")]
114            name: MaybeUndefined::Undefined,
115            title: MaybeUndefined::Undefined,
116            kind: MaybeUndefined::Undefined,
117            status: MaybeUndefined::Undefined,
118            content: MaybeUndefined::Undefined,
119            locations: MaybeUndefined::Undefined,
120            raw_input: MaybeUndefined::Undefined,
121            raw_output: MaybeUndefined::Undefined,
122            meta: MaybeUndefined::Undefined,
123        }
124    }
125
126    /// **UNSTABLE**
127    ///
128    /// This capability is not part of the spec yet, and may be removed or changed at any point.
129    ///
130    /// Programmatic name of the tool being invoked.
131    #[cfg(feature = "unstable_tool_call_name")]
132    #[must_use]
133    pub fn name(mut self, name: impl IntoMaybeUndefined<String>) -> Self {
134        self.name = name.into_maybe_undefined();
135        self
136    }
137
138    /// Human-readable title describing what the tool is doing.
139    #[must_use]
140    pub fn title(mut self, title: impl IntoMaybeUndefined<String>) -> Self {
141        self.title = title.into_maybe_undefined();
142        self
143    }
144
145    /// The category of tool being invoked.
146    /// Helps clients choose appropriate icons and UI treatment.
147    #[must_use]
148    pub fn kind(mut self, kind: impl IntoMaybeUndefined<ToolKind>) -> Self {
149        self.kind = kind.into_maybe_undefined();
150        self
151    }
152
153    /// Current execution status of the tool call.
154    #[must_use]
155    pub fn status(mut self, status: impl IntoMaybeUndefined<ToolCallStatus>) -> Self {
156        self.status = status.into_maybe_undefined();
157        self
158    }
159
160    /// Content produced by the tool call.
161    #[must_use]
162    pub fn content(mut self, content: impl IntoMaybeUndefined<Vec<ToolCallContent>>) -> Self {
163        self.content = content.into_maybe_undefined();
164        self
165    }
166
167    /// File locations affected by this tool call.
168    /// Enables "follow-along" features in clients.
169    #[must_use]
170    pub fn locations(mut self, locations: impl IntoMaybeUndefined<Vec<ToolCallLocation>>) -> Self {
171        self.locations = locations.into_maybe_undefined();
172        self
173    }
174
175    /// Raw input parameters sent to the tool.
176    #[must_use]
177    pub fn raw_input(mut self, raw_input: impl IntoMaybeUndefined<serde_json::Value>) -> Self {
178        self.raw_input = raw_input.into_maybe_undefined();
179        self
180    }
181
182    /// Raw output returned by the tool.
183    #[must_use]
184    pub fn raw_output(mut self, raw_output: impl IntoMaybeUndefined<serde_json::Value>) -> Self {
185        self.raw_output = raw_output.into_maybe_undefined();
186        self
187    }
188
189    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
190    /// metadata to their interactions. Omitted means no metadata update; `null` is an
191    /// explicit clear signal. Implementations MUST NOT make assumptions about values at these keys.
192    ///
193    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
194    #[must_use]
195    pub fn meta(mut self, meta: impl IntoMaybeUndefined<Meta>) -> Self {
196        self.meta = meta.into_maybe_undefined();
197        self
198    }
199
200    /// Applies a later tool-call patch to this stored tool-call state.
201    ///
202    /// Fields set to `null` are preserved as `null` so callers can decide how to
203    /// render an explicitly cleared value.
204    pub fn apply_update(&mut self, update: ToolCallUpdate) {
205        debug_assert_eq!(self.tool_call_id, update.tool_call_id);
206        #[cfg(feature = "unstable_tool_call_name")]
207        if !update.name.is_undefined() {
208            self.name = update.name;
209        }
210        if !update.title.is_undefined() {
211            self.title = update.title;
212        }
213        if !update.kind.is_undefined() {
214            self.kind = update.kind;
215        }
216        if !update.status.is_undefined() {
217            self.status = update.status;
218        }
219        if !update.content.is_undefined() {
220            self.content = update.content;
221        }
222        if !update.locations.is_undefined() {
223            self.locations = update.locations;
224        }
225        if !update.raw_input.is_undefined() {
226            self.raw_input = update.raw_input;
227        }
228        if !update.raw_output.is_undefined() {
229            self.raw_output = update.raw_output;
230        }
231        if !update.meta.is_undefined() {
232            self.meta = update.meta;
233        }
234    }
235}
236
237/// A streamed item of tool-call content.
238///
239/// Tool-call content chunks append one [`ToolCallContent`] item to the current
240/// content for the matching [`ToolCallId`]. Agents can use
241/// [`ToolCallUpdate::content`] when they need to replace the whole content
242/// collection instead.
243#[serde_as]
244#[skip_serializing_none]
245#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
246#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247#[serde(rename_all = "camelCase")]
248#[non_exhaustive]
249pub struct ToolCallContentChunk {
250    /// The ID of the tool call this content belongs to.
251    pub tool_call_id: ToolCallId,
252    /// A single item of content produced by the tool call.
253    pub content: ToolCallContent,
254    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
255    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
256    /// these keys. This field is chunk-scoped.
257    ///
258    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
259    #[serde_as(deserialize_as = "DefaultOnError")]
260    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
261    #[serde(default)]
262    #[serde(rename = "_meta")]
263    pub meta: Option<Meta>,
264}
265
266impl ToolCallContentChunk {
267    /// Builds [`ToolCallContentChunk`] with the required fields set; optional fields start unset or empty.
268    #[must_use]
269    pub fn new(tool_call_id: impl Into<ToolCallId>, content: impl Into<ToolCallContent>) -> Self {
270        Self {
271            tool_call_id: tool_call_id.into(),
272            content: content.into(),
273            meta: None,
274        }
275    }
276
277    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
278    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
279    /// these keys. This field is chunk-scoped.
280    ///
281    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
282    #[must_use]
283    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
284        self.meta = meta.into_option();
285        self
286    }
287}
288
289/// Unique identifier for a tool call within a session.
290#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
291#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Display, From)]
292#[serde(transparent)]
293#[from(forward)]
294#[non_exhaustive]
295pub struct ToolCallId(pub Arc<str>);
296
297impl ToolCallId {
298    /// Wraps a protocol string as a typed [`ToolCallId`].
299    #[must_use]
300    pub fn new(id: impl Into<Self>) -> Self {
301        id.into()
302    }
303}
304
305impl IntoOption<ToolCallId> for &str {
306    fn into_option(self) -> Option<ToolCallId> {
307        Some(ToolCallId::new(self))
308    }
309}
310
311/// Categories of tools that can be invoked.
312///
313/// Tool kinds help clients choose appropriate icons and optimize how they
314/// display tool execution progress.
315///
316/// See protocol docs: [Creating](https://agentclientprotocol.com/protocol/tool-calls#creating)
317#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
318#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
319#[serde(rename_all = "snake_case")]
320#[non_exhaustive]
321pub enum ToolKind {
322    /// Reading files or data.
323    Read,
324    /// Modifying files or content.
325    Edit,
326    /// Removing files or data.
327    Delete,
328    /// Moving or renaming files.
329    Move,
330    /// Searching for information.
331    Search,
332    /// Running commands or code.
333    Execute,
334    /// Internal reasoning or planning.
335    Think,
336    /// Retrieving external data.
337    Fetch,
338    /// Switching the current session mode.
339    SwitchMode,
340    /// Other tool types (default).
341    #[default]
342    Other,
343    /// Custom or future tool kind.
344    ///
345    /// Values beginning with `_` are reserved for implementation-specific
346    /// extensions. Unknown values that do not begin with `_` are reserved for
347    /// future ACP variants.
348    #[serde(untagged)]
349    Unknown(String),
350}
351
352/// Execution status of a tool call.
353///
354/// Tool calls progress through different statuses during their lifecycle.
355///
356/// See protocol docs: [Status](https://agentclientprotocol.com/protocol/tool-calls#status)
357#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
358#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
359#[serde(rename_all = "snake_case")]
360#[non_exhaustive]
361pub enum ToolCallStatus {
362    /// The tool call hasn't started running yet because the input is either
363    /// streaming or we're awaiting approval.
364    #[default]
365    Pending,
366    /// The tool call is currently running.
367    InProgress,
368    /// The tool call completed successfully.
369    Completed,
370    /// The tool call failed with an error.
371    Failed,
372    /// The tool call was cancelled before it completed.
373    Cancelled,
374    /// Custom or future tool call status.
375    ///
376    /// Values beginning with `_` are reserved for implementation-specific
377    /// extensions. Unknown values that do not begin with `_` are reserved for
378    /// future ACP variants.
379    #[serde(untagged)]
380    Other(String),
381}
382
383/// Content produced by a tool call.
384///
385/// Tool calls can produce different types of content including standard
386/// content blocks (text, images), file diffs, or display-only terminals.
387///
388/// See protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content)
389#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
390#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
391#[serde(tag = "type", rename_all = "snake_case")]
392#[non_exhaustive]
393pub enum ToolCallContent {
394    /// Standard content block (text, images, resources).
395    Content(Box<Content>),
396    /// File modification shown as a diff.
397    Diff(Diff),
398    /// A display-only reference to an agent-owned terminal.
399    Terminal(Terminal),
400    /// Custom or future tool call content.
401    ///
402    /// Values beginning with `_` are reserved for implementation-specific
403    /// extensions. Unknown values that do not begin with `_` are reserved for
404    /// future ACP variants.
405    ///
406    /// Receivers that do not understand this content type should preserve the
407    /// raw payload when storing, replaying, proxying, or forwarding tool call
408    /// output, and otherwise ignore it or display it generically.
409    #[serde(untagged)]
410    Other(OtherToolCallContent),
411}
412
413/// Custom or future tool call content payload.
414#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
415#[derive(Debug, Clone, PartialEq, Serialize)]
416#[cfg_attr(feature = "schemars", schemars(inline))]
417#[cfg_attr(feature = "schemars", schemars(transform = other_tool_call_content_schema))]
418#[serde(rename_all = "camelCase")]
419#[non_exhaustive]
420pub struct OtherToolCallContent {
421    /// Custom or future tool call content type.
422    ///
423    /// Values beginning with `_` are reserved for implementation-specific
424    /// extensions. Unknown values that do not begin with `_` are reserved for
425    /// future ACP variants.
426    #[serde(rename = "type")]
427    pub type_: String,
428    /// Additional fields from the unknown tool call content payload.
429    #[serde(flatten)]
430    pub fields: BTreeMap<String, serde_json::Value>,
431}
432
433impl OtherToolCallContent {
434    /// Builds [`OtherToolCallContent`] from an unknown discriminator and preserves the remaining extension fields.
435    #[must_use]
436    pub fn new(type_: impl Into<String>, mut fields: BTreeMap<String, serde_json::Value>) -> Self {
437        fields.remove("type");
438        Self {
439            type_: type_.into(),
440            fields,
441        }
442    }
443}
444
445impl<'de> Deserialize<'de> for OtherToolCallContent {
446    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
447    where
448        D: serde::Deserializer<'de>,
449    {
450        let mut fields = BTreeMap::<String, serde_json::Value>::deserialize(deserializer)?;
451        let type_ = fields
452            .remove("type")
453            .ok_or_else(|| serde::de::Error::missing_field("type"))?;
454        let serde_json::Value::String(type_) = type_ else {
455            return Err(serde::de::Error::custom("`type` must be a string"));
456        };
457
458        if is_known_tool_call_content_type(&type_) {
459            return Err(serde::de::Error::custom(format!(
460                "known tool call content `{type_}` did not match its schema"
461            )));
462        }
463
464        Ok(Self { type_, fields })
465    }
466}
467
468fn is_known_tool_call_content_type(type_: &str) -> bool {
469    matches!(type_, "content" | "diff" | "terminal")
470}
471
472#[cfg(feature = "schemars")]
473fn other_tool_call_content_schema(schema: &mut Schema) {
474    super::schema_util::reject_known_string_discriminators(
475        schema,
476        "type",
477        &["content", "diff", "terminal"],
478    );
479}
480
481impl<T: Into<ContentBlock>> From<T> for ToolCallContent {
482    fn from(content: T) -> Self {
483        ToolCallContent::Content(Box::new(Content::new(content)))
484    }
485}
486
487impl From<Diff> for ToolCallContent {
488    fn from(diff: Diff) -> Self {
489        ToolCallContent::Diff(diff)
490    }
491}
492
493impl From<Terminal> for ToolCallContent {
494    fn from(terminal: Terminal) -> Self {
495        ToolCallContent::Terminal(terminal)
496    }
497}
498
499/// Standard content block (text, images, resources).
500#[serde_as]
501#[skip_serializing_none]
502#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
503#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
504#[serde(rename_all = "camelCase")]
505#[non_exhaustive]
506pub struct Content {
507    /// The actual content block.
508    pub content: ContentBlock,
509    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
510    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
511    /// these keys.
512    ///
513    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
514    #[serde_as(deserialize_as = "DefaultOnError")]
515    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
516    #[serde(default)]
517    #[serde(rename = "_meta")]
518    pub meta: Option<Meta>,
519}
520
521impl Content {
522    /// Builds [`Content`] with the required fields set; optional fields start unset or empty.
523    #[must_use]
524    pub fn new(content: impl Into<ContentBlock>) -> Self {
525        Self {
526            content: content.into(),
527            meta: None,
528        }
529    }
530
531    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
532    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
533    /// these keys.
534    ///
535    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
536    #[must_use]
537    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
538        self.meta = meta.into_option();
539        self
540    }
541}
542
543/// File changes produced by a tool call.
544///
545/// `changes` is authoritative for affected absolute paths and operations.
546/// `patch` optionally carries renderable text for some or all of those changes
547/// and MUST be consistent with `changes`. Agents SHOULD provide `patch` whenever
548/// feasible. Clients MUST handle diffs where `patch` is omitted or `null`.
549///
550/// See protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content)
551#[serde_as]
552#[skip_serializing_none]
553#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
554#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
555#[serde(rename_all = "camelCase")]
556#[non_exhaustive]
557pub struct Diff {
558    /// Structured file changes described by this diff.
559    ///
560    /// Clients can use this field without parsing patch text to determine affected paths.
561    #[serde_as(deserialize_as = "VecSkipError<_, SkipListener>")]
562    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-skip-invalid-items" = true)))]
563    pub changes: Vec<DiffChange>,
564    /// Renderable patch text for some or all of the structured changes.
565    ///
566    /// Agents SHOULD provide patch text whenever feasible. Omitted or `null`
567    /// means no renderable patch text was provided.
568    #[serde_as(deserialize_as = "DefaultOnError")]
569    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
570    #[serde(default)]
571    pub patch: Option<DiffPatch>,
572    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
573    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
574    /// these keys.
575    ///
576    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
577    #[serde_as(deserialize_as = "DefaultOnError")]
578    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
579    #[serde(default)]
580    #[serde(rename = "_meta")]
581    pub meta: Option<Meta>,
582}
583
584impl Diff {
585    /// Builds [`Diff`] with structured file changes.
586    #[must_use]
587    pub fn new(changes: Vec<DiffChange>) -> Self {
588        Self {
589            changes,
590            patch: None,
591            meta: None,
592        }
593    }
594
595    /// Builds [`Diff`] with Git `--patch` text and structured file changes.
596    #[must_use]
597    pub fn patch(text: impl Into<String>, changes: Vec<DiffChange>) -> Self {
598        Self::new(changes).with_patch(DiffPatch::new(text))
599    }
600
601    /// Sets renderable patch text.
602    #[must_use]
603    pub fn with_patch(mut self, patch: impl IntoOption<DiffPatch>) -> Self {
604        self.patch = patch.into_option();
605        self
606    }
607
608    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
609    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
610    /// these keys.
611    ///
612    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
613    #[must_use]
614    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
615        self.meta = meta.into_option();
616        self
617    }
618}
619
620/// Renderable patch text and its format.
621#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
622#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
623#[serde(rename_all = "camelCase")]
624#[non_exhaustive]
625pub struct DiffPatch {
626    /// Patch format. The only ACP-defined value is `git_patch`.
627    pub format: DiffPatchFormat,
628    /// Patch text in the format named by `format`.
629    pub text: String,
630}
631
632impl DiffPatch {
633    /// Builds [`DiffPatch`] with Git `--patch` text.
634    #[must_use]
635    pub fn new(text: impl Into<String>) -> Self {
636        Self {
637            format: DiffPatchFormat::GitPatch,
638            text: text.into(),
639        }
640    }
641}
642
643/// Text patch format used by [`DiffPatch`].
644#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
645#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
646#[serde(rename_all = "snake_case")]
647#[non_exhaustive]
648pub enum DiffPatchFormat {
649    /// One or more `diff --git` sections in Git's `--patch` (`-p`) text format.
650    ///
651    /// Paths MUST be absolute. Surrounding commit metadata and email envelopes
652    /// MUST NOT be included.
653    GitPatch,
654    /// Custom or future patch format.
655    ///
656    /// Values beginning with `_` are reserved for implementation-specific
657    /// extensions. Unknown values that do not begin with `_` are reserved for
658    /// future ACP variants.
659    #[serde(untagged)]
660    Other(String),
661}
662
663/// Kind of file content represented by a diff change.
664#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
665#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
666#[serde(rename_all = "snake_case")]
667#[non_exhaustive]
668pub enum DiffFileType {
669    /// Text content.
670    Text,
671    /// Binary or otherwise non-text content.
672    Binary,
673    /// Directory entry.
674    Directory,
675    /// Symbolic link.
676    Symlink,
677    /// Custom or future file type.
678    ///
679    /// Values beginning with `_` are reserved for implementation-specific
680    /// extensions. Unknown values that do not begin with `_` are reserved for
681    /// future ACP variants.
682    #[serde(untagged)]
683    Other(String),
684}
685
686/// One file-level change described by a [`Diff`].
687///
688/// Structured change metadata lets clients identify affected files and
689/// operations without parsing the text patch.
690#[serde_as]
691#[skip_serializing_none]
692#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
693#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
694#[serde(rename_all = "camelCase")]
695#[non_exhaustive]
696pub struct DiffChange {
697    /// File content kind.
698    ///
699    /// Omitted or `null` means the content kind is unknown.
700    #[serde_as(deserialize_as = "DefaultOnError")]
701    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
702    #[serde(default)]
703    pub file_type: Option<DiffFileType>,
704    /// MIME type of the file contents.
705    ///
706    /// Omitted or `null` means the MIME type is unknown.
707    #[serde_as(deserialize_as = "DefaultOnError")]
708    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
709    #[serde(default)]
710    pub mime_type: Option<MediaType>,
711    /// File operation-specific fields.
712    #[serde(flatten)]
713    pub operation: DiffChangeOperation,
714    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
715    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
716    /// these keys.
717    ///
718    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
719    #[serde_as(deserialize_as = "DefaultOnError")]
720    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
721    #[serde(default)]
722    #[serde(rename = "_meta")]
723    pub meta: Option<Meta>,
724}
725
726impl DiffChange {
727    /// Builds [`DiffChange`] with the required fields set; optional fields start unset or empty.
728    #[must_use]
729    pub fn new(operation: DiffChangeOperation) -> Self {
730        Self {
731            file_type: None,
732            mime_type: None,
733            operation,
734            meta: None,
735        }
736    }
737
738    /// Builds a file add change.
739    #[must_use]
740    pub fn add(path: impl Into<AbsolutePath>) -> Self {
741        Self::new(DiffChangeOperation::Add(DiffPathChange::new(path)))
742    }
743
744    /// Builds a file delete change.
745    #[must_use]
746    pub fn delete(path: impl Into<AbsolutePath>) -> Self {
747        Self::new(DiffChangeOperation::Delete(DiffPathChange::new(path)))
748    }
749
750    /// Builds a file modify change.
751    #[must_use]
752    pub fn modify(path: impl Into<AbsolutePath>) -> Self {
753        Self::new(DiffChangeOperation::Modify(DiffPathChange::new(path)))
754    }
755
756    /// Builds a file move or rename change.
757    #[must_use]
758    pub fn move_file(old_path: impl Into<AbsolutePath>, path: impl Into<AbsolutePath>) -> Self {
759        Self::new(DiffChangeOperation::Move(DiffPathPairChange::new(
760            old_path, path,
761        )))
762    }
763
764    /// Builds a file copy change.
765    #[must_use]
766    pub fn copy(old_path: impl Into<AbsolutePath>, path: impl Into<AbsolutePath>) -> Self {
767        Self::new(DiffChangeOperation::Copy(DiffPathPairChange::new(
768            old_path, path,
769        )))
770    }
771
772    /// File content kind.
773    ///
774    /// Omitted or `null` means the content kind is unknown.
775    #[must_use]
776    pub fn file_type(mut self, file_type: impl IntoOption<DiffFileType>) -> Self {
777        self.file_type = file_type.into_option();
778        self
779    }
780
781    /// MIME type of the file contents.
782    ///
783    /// Omitted or `null` means the MIME type is unknown.
784    #[must_use]
785    pub fn mime_type(mut self, mime_type: impl IntoOption<MediaType>) -> Self {
786        self.mime_type = mime_type.into_option();
787        self
788    }
789
790    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
791    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
792    /// these keys.
793    ///
794    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
795    #[must_use]
796    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
797        self.meta = meta.into_option();
798        self
799    }
800}
801
802/// File operation for a [`DiffChange`].
803#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
804#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
805#[serde(tag = "operation", rename_all = "snake_case")]
806#[non_exhaustive]
807pub enum DiffChangeOperation {
808    /// A file was added.
809    Add(DiffPathChange),
810    /// A file was deleted.
811    Delete(DiffPathChange),
812    /// A file was modified in place.
813    Modify(DiffPathChange),
814    /// A file was moved or renamed.
815    Move(DiffPathPairChange),
816    /// A file was copied.
817    Copy(DiffPathPairChange),
818    /// Custom or future file operation.
819    ///
820    /// Values beginning with `_` are reserved for implementation-specific
821    /// extensions. Unknown values that do not begin with `_` are reserved for
822    /// future ACP variants.
823    #[serde(untagged)]
824    Other(OtherDiffChange),
825}
826
827/// Operation metadata for add, delete, and modify changes.
828#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
829#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
830#[serde(rename_all = "camelCase")]
831#[non_exhaustive]
832pub struct DiffPathChange {
833    /// Absolute path for the operation.
834    pub path: AbsolutePath,
835}
836
837impl DiffPathChange {
838    /// Builds [`DiffPathChange`] with the required fields set; optional fields start unset or empty.
839    #[must_use]
840    pub fn new(path: impl Into<AbsolutePath>) -> Self {
841        Self { path: path.into() }
842    }
843}
844
845/// Operation metadata for move and copy changes.
846#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
847#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
848#[serde(rename_all = "camelCase")]
849#[non_exhaustive]
850pub struct DiffPathPairChange {
851    /// Absolute path before the operation.
852    pub old_path: AbsolutePath,
853    /// Absolute path after the operation.
854    pub path: AbsolutePath,
855}
856
857impl DiffPathPairChange {
858    /// Builds [`DiffPathPairChange`] with the required fields set; optional fields start unset or empty.
859    #[must_use]
860    pub fn new(old_path: impl Into<AbsolutePath>, path: impl Into<AbsolutePath>) -> Self {
861        Self {
862            old_path: old_path.into(),
863            path: path.into(),
864        }
865    }
866}
867
868/// Custom or future file operation payload.
869#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
870#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
871#[cfg_attr(feature = "schemars", schemars(inline))]
872#[cfg_attr(feature = "schemars", schemars(transform = other_diff_change_schema))]
873#[serde(rename_all = "camelCase")]
874#[non_exhaustive]
875pub struct OtherDiffChange {
876    /// Custom or future file operation.
877    ///
878    /// Values beginning with `_` are reserved for implementation-specific
879    /// extensions. Unknown values that do not begin with `_` are reserved for
880    /// future ACP variants.
881    pub operation: String,
882    /// Additional fields from the unknown file operation payload.
883    #[serde(flatten)]
884    pub fields: BTreeMap<String, serde_json::Value>,
885}
886
887impl OtherDiffChange {
888    /// Builds [`OtherDiffChange`] from an unknown discriminator and preserves the remaining extension fields.
889    #[must_use]
890    pub fn new(
891        operation: impl Into<String>,
892        mut fields: BTreeMap<String, serde_json::Value>,
893    ) -> Self {
894        fields.remove("operation");
895        fields.remove("fileType");
896        fields.remove("mimeType");
897        fields.remove("_meta");
898        Self {
899            operation: operation.into(),
900            fields,
901        }
902    }
903}
904
905impl<'de> Deserialize<'de> for OtherDiffChange {
906    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
907    where
908        D: serde::Deserializer<'de>,
909    {
910        let mut fields = BTreeMap::<String, serde_json::Value>::deserialize(deserializer)?;
911        let operation = fields
912            .remove("operation")
913            .ok_or_else(|| serde::de::Error::missing_field("operation"))?;
914        let serde_json::Value::String(operation) = operation else {
915            return Err(serde::de::Error::custom("`operation` must be a string"));
916        };
917
918        if is_known_diff_change_operation(&operation) {
919            return Err(serde::de::Error::custom(format!(
920                "known diff change operation `{operation}` did not match its schema"
921            )));
922        }
923        fields.remove("fileType");
924        fields.remove("mimeType");
925        fields.remove("_meta");
926
927        Ok(Self { operation, fields })
928    }
929}
930
931fn is_known_diff_change_operation(operation: &str) -> bool {
932    matches!(operation, "add" | "delete" | "modify" | "move" | "copy")
933}
934
935#[cfg(feature = "schemars")]
936fn other_diff_change_schema(schema: &mut Schema) {
937    super::schema_util::reject_known_string_discriminators(
938        schema,
939        "operation",
940        &["add", "delete", "modify", "move", "copy"],
941    );
942}
943
944/// A file location being accessed or modified by a tool.
945///
946/// Enables clients to implement "follow-along" features that track
947/// which files the agent is working with in real-time.
948///
949/// See protocol docs: [Following the Agent](https://agentclientprotocol.com/protocol/tool-calls#following-the-agent)
950#[serde_as]
951#[skip_serializing_none]
952#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
953#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
954#[serde(rename_all = "camelCase")]
955#[non_exhaustive]
956pub struct ToolCallLocation {
957    /// The absolute file path being accessed or modified.
958    pub path: AbsolutePath,
959    /// Optional line number within the file.
960    #[serde_as(deserialize_as = "DefaultOnError")]
961    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
962    #[serde(default)]
963    pub line: Option<u32>,
964    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
965    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
966    /// these keys.
967    ///
968    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
969    #[serde_as(deserialize_as = "DefaultOnError")]
970    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
971    #[serde(default)]
972    #[serde(rename = "_meta")]
973    pub meta: Option<Meta>,
974}
975
976impl ToolCallLocation {
977    /// Builds [`ToolCallLocation`] with the required fields set; optional fields start unset or empty.
978    #[must_use]
979    pub fn new(path: impl Into<AbsolutePath>) -> Self {
980        Self {
981            path: path.into(),
982            line: None,
983            meta: None,
984        }
985    }
986
987    /// Optional line number within the file.
988    #[must_use]
989    pub fn line(mut self, line: impl IntoOption<u32>) -> Self {
990        self.line = line.into_option();
991        self
992    }
993
994    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
995    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
996    /// these keys.
997    ///
998    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
999    #[must_use]
1000    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
1001        self.meta = meta.into_option();
1002        self
1003    }
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008    use super::*;
1009    use crate::MaybeUndefined;
1010
1011    #[test]
1012    fn tool_call_serializes_as_upsert() {
1013        let tool_call = ToolCallUpdate::new("tc_1")
1014            .title("Reading configuration")
1015            .status(ToolCallStatus::InProgress)
1016            .raw_input(serde_json::json!({"path": "settings.json"}));
1017
1018        assert_eq!(
1019            serde_json::to_value(tool_call).unwrap(),
1020            serde_json::json!({
1021                "toolCallId": "tc_1",
1022                "title": "Reading configuration",
1023                "status": "in_progress",
1024                "rawInput": {
1025                    "path": "settings.json"
1026                }
1027            })
1028        );
1029    }
1030
1031    #[test]
1032    fn tool_call_update_distinguishes_omitted_null_and_value() {
1033        let tool_call = ToolCallUpdate::new("tc_1")
1034            .status(ToolCallStatus::Completed)
1035            .content(None::<Vec<ToolCallContent>>);
1036
1037        assert_eq!(
1038            serde_json::to_value(tool_call).unwrap(),
1039            serde_json::json!({
1040                "toolCallId": "tc_1",
1041                "status": "completed",
1042                "content": null
1043            })
1044        );
1045
1046        let deserialized: ToolCallUpdate = serde_json::from_value(serde_json::json!({
1047            "toolCallId": "tc_1",
1048            "status": null,
1049            "locations": []
1050        }))
1051        .unwrap();
1052        assert_eq!(deserialized.title, MaybeUndefined::Undefined);
1053        assert_eq!(deserialized.status, MaybeUndefined::Null);
1054        assert_eq!(deserialized.locations, MaybeUndefined::Value(Vec::new()));
1055    }
1056
1057    #[cfg(feature = "unstable_tool_call_name")]
1058    #[test]
1059    fn tool_call_name_patch_distinguishes_omitted_null_and_value() {
1060        let named = ToolCallUpdate::new("tc_1").name("read_file");
1061        assert_eq!(
1062            serde_json::to_value(named).unwrap(),
1063            serde_json::json!({
1064                "toolCallId": "tc_1",
1065                "name": "read_file"
1066            })
1067        );
1068
1069        let omitted = ToolCallUpdate::new("tc_1");
1070        assert_eq!(omitted.name, MaybeUndefined::Undefined);
1071
1072        let from_null: ToolCallUpdate = serde_json::from_value(serde_json::json!({
1073            "toolCallId": "tc_1",
1074            "name": null
1075        }))
1076        .unwrap();
1077        assert_eq!(from_null.name, MaybeUndefined::Null);
1078
1079        let mut stored = ToolCallUpdate::new("tc_1").name("read_file");
1080        stored.apply_update(ToolCallUpdate::new("tc_1"));
1081        assert_eq!(stored.name, MaybeUndefined::Value("read_file".to_string()));
1082
1083        stored.apply_update(ToolCallUpdate::new("tc_1").name(None::<String>));
1084        assert_eq!(stored.name, MaybeUndefined::Null);
1085
1086        stored.apply_update(ToolCallUpdate::new("tc_1").name("write_file"));
1087        assert_eq!(stored.name, MaybeUndefined::Value("write_file".to_string()));
1088    }
1089
1090    #[test]
1091    fn tool_call_update_distinguishes_meta_omitted_null_and_value() {
1092        let mut meta = Meta::new();
1093        meta.insert("source".to_string(), serde_json::json!("tool-call"));
1094
1095        assert_eq!(
1096            serde_json::to_value(ToolCallUpdate::new("tc_1").meta(meta.clone())).unwrap(),
1097            serde_json::json!({
1098                "toolCallId": "tc_1",
1099                "_meta": {
1100                    "source": "tool-call"
1101                }
1102            })
1103        );
1104
1105        assert_eq!(
1106            serde_json::to_value(ToolCallUpdate::new("tc_1").meta(None::<Meta>)).unwrap(),
1107            serde_json::json!({
1108                "toolCallId": "tc_1",
1109                "_meta": null
1110            })
1111        );
1112
1113        let deserialized: ToolCallUpdate = serde_json::from_value(serde_json::json!({
1114            "toolCallId": "tc_1",
1115            "_meta": null
1116        }))
1117        .unwrap();
1118        assert_eq!(deserialized.meta, MaybeUndefined::Null);
1119
1120        let patch = ToolCallUpdate::new("tc_1");
1121        assert_eq!(patch.meta, MaybeUndefined::Undefined);
1122
1123        let mut stored = ToolCallUpdate::new("tc_1").meta(meta);
1124        stored.apply_update(ToolCallUpdate::new("tc_1").meta(None::<Meta>));
1125        assert_eq!(stored.meta, MaybeUndefined::Null);
1126    }
1127
1128    #[test]
1129    fn tool_call_update_skips_malformed_list_items() {
1130        let deserialized: ToolCallUpdate = serde_json::from_value(serde_json::json!({
1131            "toolCallId": "tc_1",
1132            "content": [
1133                {
1134                    "type": "content",
1135                    "content": {
1136                        "type": "text",
1137                        "text": "ok"
1138                    }
1139                },
1140                {
1141                    "type": "diff",
1142                    "path": "/bad"
1143                }
1144            ],
1145            "locations": [
1146                {
1147                    "path": "/ok",
1148                    "line": 3
1149                },
1150                {
1151                    "line": 4
1152                }
1153            ]
1154        }))
1155        .unwrap();
1156
1157        let MaybeUndefined::Value(content) = deserialized.content else {
1158            panic!("content should deserialize to a value");
1159        };
1160        assert_eq!(content.len(), 1);
1161
1162        let MaybeUndefined::Value(locations) = deserialized.locations else {
1163            panic!("locations should deserialize to a value");
1164        };
1165        assert_eq!(locations.len(), 1);
1166    }
1167
1168    #[test]
1169    fn tool_call_content_chunk_serializes_single_content_item() {
1170        let chunk = ToolCallContentChunk::new(
1171            "tc_1",
1172            ContentBlock::Text(crate::v2::TextContent::new("partial output")),
1173        );
1174
1175        assert_eq!(
1176            serde_json::to_value(chunk).unwrap(),
1177            serde_json::json!({
1178                "toolCallId": "tc_1",
1179                "content": {
1180                    "type": "content",
1181                    "content": {
1182                        "type": "text",
1183                        "text": "partial output"
1184                    }
1185                }
1186            })
1187        );
1188    }
1189
1190    #[test]
1191    fn terminal_content_serializes_as_display_reference() {
1192        let terminal = ToolCallContent::from(Terminal::new("term_1"));
1193
1194        assert_eq!(
1195            serde_json::to_value(terminal).unwrap(),
1196            serde_json::json!({
1197                "type": "terminal",
1198                "terminalId": "term_1"
1199            })
1200        );
1201    }
1202
1203    #[test]
1204    fn diff_patch_serializes_git_patch_with_structured_changes() {
1205        let patch_text = "diff --git /repo/config.json /repo/config.json\n--- /repo/config.json\n+++ /repo/config.json\n@@ -1 +1 @@\n-old\n+new\n";
1206        let diff = ToolCallContent::Diff(Diff::patch(
1207            patch_text,
1208            vec![
1209                DiffChange::modify("/repo/config.json")
1210                    .file_type(DiffFileType::Text)
1211                    .mime_type("application/json"),
1212            ],
1213        ));
1214
1215        assert_eq!(
1216            serde_json::to_value(diff).unwrap(),
1217            serde_json::json!({
1218                "type": "diff",
1219                "changes": [
1220                    {
1221                        "operation": "modify",
1222                        "path": "/repo/config.json",
1223                        "fileType": "text",
1224                        "mimeType": "application/json"
1225                    }
1226                ],
1227                "patch": {
1228                    "format": "git_patch",
1229                    "text": patch_text
1230                }
1231            })
1232        );
1233    }
1234
1235    #[test]
1236    fn diff_patch_requires_text() {
1237        let result = serde_json::from_value::<DiffPatch>(serde_json::json!({
1238            "format": "git_patch",
1239            "diff": "diff --git /repo/config.json /repo/config.json\n"
1240        }));
1241
1242        assert!(result.is_err());
1243    }
1244
1245    #[test]
1246    fn diff_serializes_binary_modify_without_patch_text() {
1247        let diff = ToolCallContent::Diff(Diff::new(vec![
1248            DiffChange::modify("/repo/assets/logo.png")
1249                .file_type(DiffFileType::Binary)
1250                .mime_type("image/png"),
1251        ]));
1252
1253        assert_eq!(
1254            serde_json::to_value(diff).unwrap(),
1255            serde_json::json!({
1256                "type": "diff",
1257                "changes": [
1258                    {
1259                        "operation": "modify",
1260                        "path": "/repo/assets/logo.png",
1261                        "fileType": "binary",
1262                        "mimeType": "image/png"
1263                    }
1264                ]
1265            })
1266        );
1267    }
1268
1269    #[test]
1270    fn diff_move_serializes_shared_fields_with_operation_payload() {
1271        let diff = ToolCallContent::Diff(Diff::new(vec![
1272            DiffChange::move_file("/repo/src/old.rs", "/repo/src/new.rs")
1273                .file_type(DiffFileType::Text)
1274                .mime_type("text/rust"),
1275        ]));
1276
1277        assert_eq!(
1278            serde_json::to_value(diff).unwrap(),
1279            serde_json::json!({
1280                "type": "diff",
1281                "changes": [
1282                    {
1283                        "operation": "move",
1284                        "oldPath": "/repo/src/old.rs",
1285                        "path": "/repo/src/new.rs",
1286                        "fileType": "text",
1287                        "mimeType": "text/rust"
1288                    }
1289                ]
1290            })
1291        );
1292    }
1293
1294    #[test]
1295    fn diff_changes_skip_malformed_list_items() {
1296        let patch_text = "diff --git /ok /ok\ndeleted file mode 100644\n--- /ok\n+++ /dev/null\n@@ -1 +0,0 @@\n-old\n";
1297        let content: ToolCallContent = serde_json::from_value(serde_json::json!({
1298            "type": "diff",
1299            "changes": [
1300                {
1301                    "operation": "modify"
1302                },
1303                {
1304                    "operation": "delete",
1305                    "path": "/ok"
1306                }
1307            ],
1308            "patch": {
1309                "format": "git_patch",
1310                "text": patch_text
1311            }
1312        }))
1313        .unwrap();
1314
1315        let ToolCallContent::Diff(diff) = content else {
1316            panic!("expected diff content");
1317        };
1318        assert_eq!(diff.changes, vec![DiffChange::delete("/ok")]);
1319        assert_eq!(diff.patch, Some(DiffPatch::new(patch_text)));
1320    }
1321
1322    #[test]
1323    fn tool_kind_preserves_unknown_variant() {
1324        let kind: ToolKind = serde_json::from_str("\"review\"").unwrap();
1325        assert_eq!(kind, ToolKind::Unknown("review".to_string()));
1326        assert_eq!(serde_json::to_value(&kind).unwrap(), "review");
1327    }
1328
1329    #[test]
1330    fn tool_call_status_preserves_unknown_variant() {
1331        let status: ToolCallStatus = serde_json::from_str("\"deferred\"").unwrap();
1332        assert_eq!(status, ToolCallStatus::Other("deferred".to_string()));
1333        assert_eq!(serde_json::to_value(&status).unwrap(), "deferred");
1334    }
1335
1336    #[test]
1337    fn tool_call_status_recognizes_cancelled_variant() {
1338        let status: ToolCallStatus = serde_json::from_str("\"cancelled\"").unwrap();
1339        assert_eq!(status, ToolCallStatus::Cancelled);
1340        assert_eq!(serde_json::to_value(&status).unwrap(), "cancelled");
1341    }
1342
1343    #[test]
1344    fn tool_call_content_preserves_unknown_variant() {
1345        let content: ToolCallContent = serde_json::from_value(serde_json::json!({
1346            "type": "_chart",
1347            "title": "Tests",
1348            "data": [1, 2, 3]
1349        }))
1350        .unwrap();
1351
1352        let ToolCallContent::Other(unknown) = content else {
1353            panic!("expected unknown tool call content");
1354        };
1355
1356        assert_eq!(unknown.type_, "_chart");
1357        assert_eq!(
1358            unknown.fields.get("title"),
1359            Some(&serde_json::json!("Tests"))
1360        );
1361        assert_eq!(
1362            serde_json::to_value(ToolCallContent::Other(unknown)).unwrap(),
1363            serde_json::json!({
1364                "type": "_chart",
1365                "title": "Tests",
1366                "data": [1, 2, 3]
1367            })
1368        );
1369    }
1370
1371    #[test]
1372    fn tool_call_content_does_not_hide_malformed_known_variant() {
1373        assert!(
1374            serde_json::from_value::<ToolCallContent>(serde_json::json!({
1375                "type": "diff"
1376            }))
1377            .is_err()
1378        );
1379        assert!(
1380            serde_json::from_value::<ToolCallContent>(serde_json::json!({
1381                "type": "terminal"
1382            }))
1383            .is_err()
1384        );
1385    }
1386}