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