Skip to main content

protocol_droid/domain/acp/
content.rs

1//! `ContentBlock` — the typed content the `session/prompt`
2//! and `session/update` paths carry.
3//!
4//! Source of truth:
5//! `crates/protocol-droid-schema/spec/acp/v1.25.0/schema.json`
6//! under `$defs.ContentBlock` and the variant `$ref`s.
7//!
8//! # Why a typed union
9//!
10//! The schema names five variants: `text`, `image`,
11//! `audio`, `resource_link`, and `resource`. The
12//! `session/prompt` `prompt` field is `Vec<ContentBlock>`;
13//! the agent's `session/update` notifications carry
14//! content blocks too. The typed form matches what the
15//! schema says, so a consumer that constructs a prompt
16//! block by block gets the wire form for free.
17//!
18//! The schema's `x-deserialize-default-on-error` annotation
19//! on the variant fields means a wire block that the typed
20//! form does not model round-trips through `Other(Value)`
21//! — the bridge can forward an unknown block losslessly
22//! without losing data, and the consumer that does not
23//! care about that variant just ignores it.
24//!
25//! # The "required for prompts" baseline
26//!
27//! `Text` and `ResourceLink` are the two variants the
28//! schema says every agent MUST support. `Image`,
29//! `Audio`, and `Resource` are gated on the corresponding
30//! `PromptCapabilities` (the `image`, `audio`, and
31//! `embeddedContext` flags). A consumer that constructs
32//! an `Image` block against an agent that did not
33//! advertise `image` is a wire-level error; the typed
34//! form does not enforce that gate because the gate is
35//! the dispatcher's job, not the data layer's.
36
37use std::collections::BTreeMap;
38
39use serde::{Deserialize, Serialize};
40
41/// A single block of content the agent and client exchange.
42///
43/// The discriminator is the `type` field; the variants
44/// are the five the schema names plus a `passthrough`
45/// variant for blocks the typed form does not yet
46/// model.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(tag = "type", rename_all = "snake_case")]
49pub enum ContentBlock {
50    /// Plain text or Markdown. The schema says every
51    /// agent MUST support this in prompts.
52    Text(TextContent),
53    /// An image. The agent advertises support via
54    /// `PromptCapabilities::image`.
55    Image(ImageContent),
56    /// An audio clip. The agent advertises support via
57    /// `PromptCapabilities::audio`.
58    Audio(AudioContent),
59    /// A link to an external resource. The schema says
60    /// every agent MUST support this in prompts; the
61    /// block is metadata, not the resource body.
62    ResourceLink(ResourceLink),
63    /// An inline resource. The agent advertises support
64    /// via `PromptCapabilities::embedded_context`.
65    Resource(EmbeddedResource),
66    /// A block the typed form does not model. The wire
67    /// payload is preserved verbatim in the
68    /// `serde_json::Value` so the bridge can forward it
69    /// losslessly.
70    #[serde(untagged)]
71    Other(serde_json::Value),
72}
73
74/// A `text` content block.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct TextContent {
78    /// The text. May be plain text or Markdown; the
79    /// schema does not constrain the formatting.
80    pub text: String,
81    /// Optional annotations. Reserved for the
82    /// `_meta`-style extensions the schema
83    /// documents.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub annotations: Option<Annotations>,
86    /// Per-implementation metadata.
87    #[serde(default, rename = "_meta", skip_serializing_if = "BTreeMap::is_empty")]
88    pub meta: BTreeMap<String, serde_json::Value>,
89}
90
91/// An `image` content block.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct ImageContent {
95    /// The image data, base64-encoded. The schema
96    /// does not constrain the encoding beyond
97    /// "base64".
98    pub data: String,
99    /// The MIME type, e.g. `image/png`.
100    pub mime_type: String,
101    /// Optional annotations.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub annotations: Option<Annotations>,
104    /// Per-implementation metadata.
105    #[serde(default, rename = "_meta", skip_serializing_if = "BTreeMap::is_empty")]
106    pub meta: BTreeMap<String, serde_json::Value>,
107}
108
109/// An `audio` content block.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct AudioContent {
113    /// The audio data, base64-encoded.
114    pub data: String,
115    /// The MIME type, e.g. `audio/mp3`.
116    pub mime_type: String,
117    /// Optional annotations.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub annotations: Option<Annotations>,
120    /// Per-implementation metadata.
121    #[serde(default, rename = "_meta", skip_serializing_if = "BTreeMap::is_empty")]
122    pub meta: BTreeMap<String, serde_json::Value>,
123}
124
125/// A `resource_link` content block. A pointer to an
126/// external resource the consumer may or may not
127/// dereference; the block is the metadata, not the
128/// resource body.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "camelCase")]
131pub struct ResourceLink {
132    /// A URI for the resource. The schema says "any
133    /// URI scheme the editor recognises"; `file://` is
134    /// the most common.
135    pub uri: String,
136    /// Human-readable name.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub name: Option<String>,
139    /// Human-readable description.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub description: Option<String>,
142    /// The MIME type, if known.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub mime_type: Option<String>,
145    /// The size in bytes, if known.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub size: Option<u64>,
148    /// Optional annotations.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub annotations: Option<Annotations>,
151    /// Per-implementation metadata.
152    #[serde(default, rename = "_meta", skip_serializing_if = "BTreeMap::is_empty")]
153    pub meta: BTreeMap<String, serde_json::Value>,
154}
155
156/// A `resource` content block. The resource body is
157/// inline; the block carries the same metadata as
158/// `ResourceLink` plus the body itself.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "camelCase")]
161pub struct EmbeddedResource {
162    /// The resource body, embedded.
163    pub resource: EmbeddedResourceBody,
164    /// Optional annotations.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub annotations: Option<Annotations>,
167    /// Per-implementation metadata.
168    #[serde(default, rename = "_meta", skip_serializing_if = "BTreeMap::is_empty")]
169    pub meta: BTreeMap<String, serde_json::Value>,
170}
171
172/// The body of an [`EmbeddedResource`]. A text
173/// resource is a `String`; a binary resource is
174/// base64-encoded.
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(tag = "mimeType", rename_all = "camelCase")]
177pub enum EmbeddedResourceBody {
178    /// A text resource. The MIME type is the tag;
179    /// e.g. `text/plain` for plain text or
180    /// `text/markdown` for Markdown.
181    Text {
182        /// The text body.
183        text: String,
184    },
185    /// A binary resource. The MIME type is the tag;
186    /// the data is base64-encoded.
187    Blob {
188        /// The base64-encoded body.
189        blob: String,
190    },
191}
192
193/// Optional annotations a content block carries. The
194/// schema names two fields: `audience` (who the block
195/// is for) and `priority` (how important the block is).
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub struct Annotations {
199    /// Who the block is for. `["user"]`, `["assistant"]`,
200    /// or both.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub audience: Option<Vec<ContentAudience>>,
203    /// The priority the block should be rendered with.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub priority: Option<ContentPriority>,
206}
207
208/// The audience a content block is for.
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename_all = "lowercase")]
211pub enum ContentAudience {
212    /// The end user reading the agent's output.
213    User,
214    /// The agent itself, processing the block as
215    /// context.
216    Assistant,
217}
218
219/// The priority a content block should be rendered
220/// with. The schema is a string union; the typed
221/// form is the same five-value space.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "lowercase")]
224pub enum ContentPriority {
225    /// The block is incidental; the UI may render it
226    /// behind a "show details" affordance.
227    Background,
228    /// The default priority.
229    Normal,
230    /// The block is important; the UI should surface
231    /// it prominently.
232    High,
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use serde_json::json;
239
240    /// The `text` content block round-trips through
241    /// `Serialize` → `Deserialize`. The `type`
242    /// discriminator is the variant tag.
243    #[test]
244    fn a_text_content_block_round_trips() -> Result<(), serde_json::Error> {
245        let block = ContentBlock::Text(TextContent {
246            text: "hello".to_owned(),
247            annotations: None,
248            meta: BTreeMap::new(),
249        });
250        let value = serde_json::to_value(&block)?;
251        assert_eq!(value.get("type"), Some(&json!("text")));
252        assert_eq!(value.get("text"), Some(&json!("hello")));
253        let round: ContentBlock = serde_json::from_value(value)?;
254        assert_eq!(round, block);
255        Ok(())
256    }
257
258    /// The `image` content block carries a base64
259    /// payload and a MIME type on the wire.
260    #[test]
261    fn an_image_content_block_round_trips() -> Result<(), serde_json::Error> {
262        let block = ContentBlock::Image(ImageContent {
263            data: "aGVsbG8=".to_owned(),
264            mime_type: "image/png".to_owned(),
265            annotations: None,
266            meta: BTreeMap::new(),
267        });
268        let value = serde_json::to_value(&block)?;
269        assert_eq!(value.get("type"), Some(&json!("image")));
270        assert_eq!(value.get("data"), Some(&json!("aGVsbG8=")));
271        assert_eq!(value.get("mimeType"), Some(&json!("image/png")));
272        Ok(())
273    }
274
275    /// The `audio` content block carries a base64
276    /// payload and a MIME type.
277    #[test]
278    fn an_audio_content_block_round_trips() -> Result<(), serde_json::Error> {
279        let block = ContentBlock::Audio(AudioContent {
280            data: "ZGF0YQ==".to_owned(),
281            mime_type: "audio/mp3".to_owned(),
282            annotations: None,
283            meta: BTreeMap::new(),
284        });
285        let value = serde_json::to_value(&block)?;
286        assert_eq!(value.get("type"), Some(&json!("audio")));
287        assert_eq!(value.get("mimeType"), Some(&json!("audio/mp3")));
288        Ok(())
289    }
290
291    /// A `resource_link` block carries the URI
292    /// metadata; the body is not in the block.
293    #[test]
294    fn a_resource_link_block_round_trips() -> Result<(), serde_json::Error> {
295        let block = ContentBlock::ResourceLink(ResourceLink {
296            uri: "file:///tmp/example.txt".to_owned(),
297            name: Some("example.txt".to_owned()),
298            description: None,
299            mime_type: Some("text/plain".to_owned()),
300            size: Some(12),
301            annotations: None,
302            meta: BTreeMap::new(),
303        });
304        let value = serde_json::to_value(&block)?;
305        assert_eq!(value.get("type"), Some(&json!("resource_link")));
306        assert_eq!(value.get("uri"), Some(&json!("file:///tmp/example.txt")));
307        assert_eq!(value.get("size"), Some(&json!(12)));
308        Ok(())
309    }
310
311    /// An `embedded_resource` block carries the body
312    /// inline. A text body uses the text payload; a
313    /// binary body uses the base64-encoded blob.
314    #[test]
315    fn an_embedded_resource_text_block_round_trips() -> Result<(), serde_json::Error> {
316        let block = ContentBlock::Resource(EmbeddedResource {
317            resource: EmbeddedResourceBody::Text {
318                text: "inline content".to_owned(),
319            },
320            annotations: None,
321            meta: BTreeMap::new(),
322        });
323        let value = serde_json::to_value(&block)?;
324        assert_eq!(value.get("type"), Some(&json!("resource")));
325        let resource = value.get("resource").ok_or_else(|| {
326            serde_json::Error::io(std::io::Error::other("missing `resource` field"))
327        })?;
328        assert_eq!(resource.get("text"), Some(&json!("inline content")));
329        Ok(())
330    }
331
332    /// An unknown `type` value deserialises into the
333    /// `Other` passthrough variant. The wire payload
334    /// is preserved verbatim for the bridge to forward.
335    #[test]
336    fn an_unknown_content_type_lands_in_the_other_variant() -> Result<(), serde_json::Error> {
337        let raw = json!({"type": "future_block", "payload": "anything"});
338        let block: ContentBlock = serde_json::from_value(raw)?;
339        match block {
340            ContentBlock::Other(value) => {
341                assert_eq!(value.get("type"), Some(&json!("future_block")));
342            }
343            ContentBlock::Text(_)
344            | ContentBlock::Image(_)
345            | ContentBlock::Audio(_)
346            | ContentBlock::ResourceLink(_)
347            | ContentBlock::Resource(_) => {
348                return Err(serde_json::Error::io(std::io::Error::other(
349                    "an unknown type landed in the wrong variant",
350                )));
351            }
352        }
353        Ok(())
354    }
355}