Skip to main content

forge_primitives/
content.rs

1//! Content blocks inside assistant and user messages.
2//!
3//! SDK's `TextBlock`, `ThinkingBlock`, `ToolUseBlock`,
4//! `ToolResultBlock`. Wire shape is `{"type": "...", ...}`. Unknown
5//! block types land in [`ContentBlock::Unknown`] so the decoder is
6//! forward-compatible with new Anthropic API blocks (`document`,
7//! future types) without an SDK bump - mirrors the top-level
8//! the SDK's `DecodedLine::Unknown`
9//! fallback.
10
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12use serde_json::Value;
13
14use crate::messages::Extras;
15
16/// A single block inside an assistant turn's `content` array or a user
17/// message's tool-result envelope.
18#[derive(Debug, Clone, PartialEq)]
19pub enum ContentBlock {
20    /// Plain text from the assistant.
21    Text {
22        /// The text payload.
23        text: String,
24        /// Fields of the block's own object that forge does not model.
25        extras: Extras,
26    },
27
28    /// Extended-thinking reasoning block. Present when the model was run in
29    /// thinking mode.
30    Thinking {
31        /// The reasoning text.
32        thinking: String,
33        /// Anthropic's signature for the thinking block.
34        signature: String,
35        /// Fields of the block's own object that forge does not model.
36        extras: Extras,
37    },
38
39    /// The model wants to invoke a tool.
40    ToolUse {
41        /// Opaque id Anthropic assigns to this tool invocation.
42        id: String,
43        /// Tool name (e.g. `"Edit"`, `"Bash"`, `"mcp__foo__bar"`).
44        name: String,
45        /// JSON input the model generated for this tool.
46        input: Value,
47        /// Fields of the block's own object that forge does not model
48        /// (`caller`, for one).
49        extras: Extras,
50    },
51
52    /// A tool's output, sent back to the model in a user turn.
53    ToolResult {
54        /// The `id` from the corresponding `ToolUse` block.
55        tool_use_id: String,
56        /// Rendered tool output (often a string, may be a JSON object in the wire).
57        content: Value,
58        /// Whether the tool reported failure.
59        is_error: bool,
60        /// Fields of the block's own object that forge does not model.
61        extras: Extras,
62    },
63
64    /// Server-side tool invocation the API executed on the model's behalf
65    /// (e.g. `advisor`, `web_search`, `web_fetch`). Surfaces alongside
66    /// regular `ToolUse` blocks but the caller never returns a result -
67    /// the server supplies one in a matching [`ContentBlock::ServerToolResult`].
68    /// `ServerToolUseBlock`.
69    ///
70    /// `name` is a discriminator: typed as the Literal set
71    /// `{"advisor", "web_search", "web_fetch", "code_execution",
72    /// "bash_code_execution", "text_editor_code_execution",
73    /// "tool_search_tool_regex", "tool_search_tool_bm25"}`. forge-sdk
74    /// keeps it as [`String`] so new server tools don't require an SDK
75    /// bump to deserialise.
76    ServerToolUse {
77        /// Opaque id Anthropic assigns to this server-tool invocation.
78        id: String,
79        /// Server-tool name (see type-level docs for the current Literal set).
80        name: String,
81        /// JSON input the model generated for this call.
82        input: Value,
83        /// Fields of the block's own object that forge does not model.
84        extras: Extras,
85    },
86
87    /// Result block for a server-side tool call (wire type
88    /// `advisor_tool_result`). Same wire shape as `ToolResult` but
89    /// the `content` dict is opaque - branch on `content["type"]`
90    /// to tell which concrete server-tool result schema applies (e.g.
91    /// `advisor_result` vs. `advisor_redacted_result`).
92    ServerToolResult {
93        /// The `id` from the corresponding [`ContentBlock::ServerToolUse`] block.
94        tool_use_id: String,
95        /// Raw server-tool result payload. Schema depends on the server
96        /// tool - inspect `content["type"]` for the concrete shape.
97        content: Value,
98        /// Fields of the block's own object that forge does not model.
99        extras: Extras,
100    },
101
102    /// Document attachment (PDF, etc.) inlined into a user turn. The
103    /// Anthropic API supports these directly - when the user attaches
104    /// a file to a Claude Code conversation, the CLI emits
105    /// `{"type":"document","source":{"type":"base64",
106    /// "media_type":"<mime>","data":"<base64>"}}` (or `"type":"url"` /
107    /// `"type":"text"` for the alternate source variants).
108    ///
109    /// `source` stays a [`Value`] rather than a typed struct because
110    /// Anthropic's source schema branches on `source.type`
111    /// (`base64` / `url` / `text` / `content`) and each variant
112    /// carries different fields - keeping it as a JSON object lets
113    /// new source types decode without an SDK bump.
114    Document {
115        /// Source of the document - JSON object whose `type` field
116        /// discriminates between `base64`, `url`, `text`, `content`.
117        source: Value,
118        /// Fields of the block's own object that forge does not model.
119        extras: Extras,
120    },
121
122    /// Image attachment (JPEG / PNG / GIF / WEBP) inlined into a user
123    /// turn. Same shape as [`ContentBlock::Document`] but with
124    /// `"type":"image"` on the wire. Surfaced when a user pastes or
125    /// attaches an image in a Claude Code conversation.
126    Image {
127        /// Source of the image - JSON object whose `type` field
128        /// discriminates between `base64`, `url`, etc.
129        source: Value,
130        /// Fields of the block's own object that forge does not model.
131        extras: Extras,
132    },
133
134    /// User input that was typed while a previous turn was in flight
135    /// and got "queued" by the CLI. Claude Code packages these as
136    /// `queued_command`-type content blocks on the next outbound
137    /// user-message envelope going to the model, so the model sees
138    /// them as additional context.
139    ///
140    /// Wire shape (from the CLI binary's `gO6` function):
141    /// ```json
142    /// {"type": "queued_command",
143    ///  "prompt": "<text or content-block list>",
144    ///  "commandMode": "prompt" | "bash",
145    ///  "source_uuid": "<optional>",
146    ///  ...}
147    /// ```
148    ///
149    /// Forge surfaces this so resumed sessions render the queued
150    /// inputs as user bubbles in the chat. Without it, a session
151    /// that had mid-turn queued inputs would replay missing those
152    /// user messages.
153    QueuedCommand {
154        /// The queued user text (or content-block array - kept as
155        /// `Value` so list-of-blocks variants pass through cleanly).
156        prompt: Value,
157        /// Mode the input was submitted in. Typically `"prompt"`;
158        /// `"bash"` for `!`-prefixed shell commands.
159        command_mode: Option<String>,
160        /// Source UUID assigned by the CLI when the input was queued.
161        /// Useful for de-duplication / threading; not load-bearing
162        /// for rendering.
163        source_uuid: Option<String>,
164        /// Fields of the block's own object that forge does not model.
165        extras: Extras,
166    },
167
168    /// Forward-compat fallback for content block types forge-sdk
169    /// doesn't model explicitly. Callers can branch on `type_str` +
170    /// inspect `raw` - the decoder never errors on an unrecognised
171    /// block. Mirrors the top-level
172    /// the SDK's `DecodedLine::Unknown`
173    /// fallback pattern.
174    Unknown {
175        /// The unrecognised `type` field value.
176        type_str: String,
177        /// Full JSON payload for the block.
178        raw: Value,
179    },
180}
181
182/// Wire-shape `type` discriminator used by the custom (de)serialise
183/// impls. The discriminator mirrors the original `#[serde(tag="type")]`
184/// layout but lets us fall through to [`ContentBlock::Unknown`] for
185/// anything we don't recognise.
186impl Serialize for ContentBlock {
187    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
188        let v = match self {
189            ContentBlock::Text { text, extras } => {
190                with_extras(serde_json::json!({"type": "text", "text": text}), extras)
191            }
192            ContentBlock::Thinking { thinking, signature, extras } => with_extras(
193                serde_json::json!({
194                    "type": "thinking",
195                    "thinking": thinking,
196                    "signature": signature,
197                }),
198                extras,
199            ),
200            ContentBlock::ToolUse { id, name, input, extras } => with_extras(
201                serde_json::json!({
202                    "type": "tool_use",
203                    "id": id,
204                    "name": name,
205                    "input": input,
206                }),
207                extras,
208            ),
209            ContentBlock::ToolResult { tool_use_id, content, is_error, extras } => with_extras(
210                serde_json::json!({
211                    "type": "tool_result",
212                    "tool_use_id": tool_use_id,
213                    "content": content,
214                    "is_error": is_error,
215                }),
216                extras,
217            ),
218            ContentBlock::ServerToolUse { id, name, input, extras } => with_extras(
219                serde_json::json!({
220                    "type": "server_tool_use",
221                    "id": id,
222                    "name": name,
223                    "input": input,
224                }),
225                extras,
226            ),
227            ContentBlock::ServerToolResult { tool_use_id, content, extras } => with_extras(
228                serde_json::json!({
229                    "type": "advisor_tool_result",
230                    "tool_use_id": tool_use_id,
231                    "content": content,
232                }),
233                extras,
234            ),
235            ContentBlock::Document { source, extras } => with_extras(
236                serde_json::json!({
237                    "type": "document",
238                    "source": source,
239                }),
240                extras,
241            ),
242            ContentBlock::Image { source, extras } => with_extras(
243                serde_json::json!({
244                    "type": "image",
245                    "source": source,
246                }),
247                extras,
248            ),
249            ContentBlock::QueuedCommand { prompt, command_mode, source_uuid, extras } => {
250                let mut obj = serde_json::Map::new();
251                obj.insert("type".into(), Value::String("queued_command".into()));
252                obj.insert("prompt".into(), prompt.clone());
253                if let Some(mode) = command_mode {
254                    obj.insert("commandMode".into(), Value::String(mode.clone()));
255                }
256                if let Some(uuid) = source_uuid {
257                    obj.insert("source_uuid".into(), Value::String(uuid.clone()));
258                }
259                for (key, value) in extras {
260                    obj.entry(key.clone()).or_insert_with(|| value.clone());
261                }
262                Value::Object(obj)
263            }
264            ContentBlock::Unknown { raw, .. } => raw.clone(),
265        };
266        v.serialize(serializer)
267    }
268}
269
270/// Merge a block's unmodelled fields into its serialized object. A key the
271/// typed arm wrote wins, so an extra can never shadow a modelled field.
272fn with_extras(mut value: Value, extras: &Extras) -> Value {
273    if let Some(obj) = value.as_object_mut() {
274        for (key, field) in extras {
275            obj.entry(key.clone()).or_insert_with(|| field.clone());
276        }
277    }
278    value
279}
280
281/// The block's own object minus the keys its typed arm read, kept verbatim
282/// so a decode is lossless.
283fn extras_of(raw: &Value, known: &[&str]) -> Extras {
284    let Some(obj) = raw.as_object() else {
285        return Extras::new();
286    };
287    obj.iter()
288        .filter(|(key, _)| !known.contains(&key.as_str()))
289        .map(|(key, value)| (key.clone(), value.clone()))
290        .collect()
291}
292
293impl<'de> Deserialize<'de> for ContentBlock {
294    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
295        let raw = Value::deserialize(deserializer)?;
296        // Distinguish "missing" (wire corruption) from "unrecognised"
297        // (forward-compat drift) so debug logs + `Unknown` payloads carry
298        // the right signal. ControlRequestKind::Deserialize`'s
299        // sentinel handling in `src/control.rs`.
300        let ty = raw.get("type").and_then(Value::as_str).unwrap_or("<missing>").to_string();
301        match ty.as_str() {
302            "text" => Ok(ContentBlock::Text {
303                text: raw.get("text").and_then(Value::as_str).unwrap_or_default().to_string(),
304                extras: extras_of(&raw, &["type", "text"]),
305            }),
306            "thinking" => Ok(ContentBlock::Thinking {
307                thinking: raw
308                    .get("thinking")
309                    .and_then(Value::as_str)
310                    .unwrap_or_default()
311                    .to_string(),
312                signature: raw
313                    .get("signature")
314                    .and_then(Value::as_str)
315                    .unwrap_or_default()
316                    .to_string(),
317                extras: extras_of(&raw, &["type", "thinking", "signature"]),
318            }),
319            "tool_use" => Ok(ContentBlock::ToolUse {
320                id: raw.get("id").and_then(Value::as_str).unwrap_or_default().to_string(),
321                name: raw.get("name").and_then(Value::as_str).unwrap_or_default().to_string(),
322                input: raw.get("input").cloned().unwrap_or(Value::Null),
323                extras: extras_of(&raw, &["type", "id", "name", "input"]),
324            }),
325            "tool_result" => Ok(ContentBlock::ToolResult {
326                tool_use_id: raw
327                    .get("tool_use_id")
328                    .and_then(Value::as_str)
329                    .unwrap_or_default()
330                    .to_string(),
331                content: raw.get("content").cloned().unwrap_or(Value::Null),
332                is_error: raw.get("is_error").and_then(Value::as_bool).unwrap_or(false),
333                extras: extras_of(&raw, &["type", "tool_use_id", "content", "is_error"]),
334            }),
335            "server_tool_use" => Ok(ContentBlock::ServerToolUse {
336                id: raw.get("id").and_then(Value::as_str).unwrap_or_default().to_string(),
337                name: raw.get("name").and_then(Value::as_str).unwrap_or_default().to_string(),
338                input: raw.get("input").cloned().unwrap_or(Value::Null),
339                extras: extras_of(&raw, &["type", "id", "name", "input"]),
340            }),
341            "advisor_tool_result" => Ok(ContentBlock::ServerToolResult {
342                tool_use_id: raw
343                    .get("tool_use_id")
344                    .and_then(Value::as_str)
345                    .unwrap_or_default()
346                    .to_string(),
347                content: raw.get("content").cloned().unwrap_or(Value::Null),
348                extras: extras_of(&raw, &["type", "tool_use_id", "content"]),
349            }),
350            "document" | "image" => {
351                let source = raw.get("source").cloned().unwrap_or(Value::Null);
352                if source.is_null() {
353                    // Required field per Anthropic API spec - log so a
354                    // CLI bug emitting a partial block surfaces in
355                    // logs instead of decoding silently to a
356                    // semantically-broken `Document { source: Null }`.
357                    tracing::warn!(
358                        kind = %ty,
359                        "content block decoded with missing/null `source`; \
360                         consumer-side branching on `source[\"type\"]` will see Null"
361                    );
362                }
363                Ok(if ty == "document" {
364                    ContentBlock::Document { source, extras: extras_of(&raw, &["type", "source"]) }
365                } else {
366                    ContentBlock::Image { source, extras: extras_of(&raw, &["type", "source"]) }
367                })
368            }
369            "queued_command" => Ok(ContentBlock::QueuedCommand {
370                prompt: raw.get("prompt").cloned().unwrap_or(Value::Null),
371                command_mode: raw.get("commandMode").and_then(Value::as_str).map(str::to_string),
372                source_uuid: raw.get("source_uuid").and_then(Value::as_str).map(str::to_string),
373                extras: extras_of(&raw, &["type", "prompt", "commandMode", "source_uuid"]),
374            }),
375            other => Ok(ContentBlock::Unknown { type_str: other.to_string(), raw }),
376        }
377    }
378}
379
380#[cfg(test)]
381mod tests_content_roundtrip {
382    // Test-mod `use super::*;` brings the parent's full surface in; not every test consumes every item.
383    #[allow(unused_imports)]
384    use super::*;
385
386    use crate::content::ContentBlock;
387    use serde_json::json;
388
389    #[test]
390    fn text_block_roundtrip() {
391        let raw = json!({"type": "text", "text": "hello world"});
392        let block: ContentBlock = serde_json::from_value(raw.clone()).expect("parse");
393        matches!(block, ContentBlock::Text { .. }).then_some(()).expect("text variant");
394        let re = serde_json::to_value(&block).expect("serialize");
395        assert_eq!(raw, re);
396    }
397
398    #[test]
399    fn thinking_block_roundtrip() {
400        let raw =
401            json!({"type": "thinking", "thinking": "let me think...", "signature": "sig-abc"});
402        let block: ContentBlock = serde_json::from_value(raw.clone()).expect("parse");
403        matches!(block, ContentBlock::Thinking { .. }).then_some(()).expect("thinking variant");
404        let re = serde_json::to_value(&block).expect("serialize");
405        assert_eq!(raw, re);
406    }
407
408    /// A block's own object carries keys this build does not model
409    /// (`caller` on a tool_use, for one), and they cross: the block is
410    /// hand-(de)serialized, so this is the level a flatten most plausibly
411    /// leaks at.
412    #[test]
413    fn a_block_keeps_unmodelled_keys_in_its_own_object() {
414        let raw = json!({
415            "type": "tool_use",
416            "id": "toolu_1",
417            "name": "Agent",
418            "input": {"prompt": "do it"},
419            "caller": {"type": "direct"},
420        });
421        let block: ContentBlock = serde_json::from_value(raw.clone()).expect("parse");
422        let re = serde_json::to_value(&block).expect("serialize");
423        assert_eq!(re, raw, "the caller crosses whole");
424
425        let raw = json!({
426            "type": "tool_result",
427            "tool_use_id": "toolu_1",
428            "content": "ok",
429            "is_error": false,
430            "elapsed_ms": 12,
431        });
432        let block: ContentBlock = serde_json::from_value(raw.clone()).expect("parse");
433        let re = serde_json::to_value(&block).expect("serialize");
434        assert_eq!(re, raw, "and an unmodelled result key does too");
435
436        // The control: a known key read by the typed arm is never
437        // duplicated from the extras.
438        let block: ContentBlock = serde_json::from_value(json!({
439            "type": "text",
440            "text": "hi",
441        }))
442        .expect("parse");
443        let re = serde_json::to_value(&block).expect("serialize");
444        assert_eq!(re, json!({"type": "text", "text": "hi"}));
445    }
446
447    #[test]
448    fn tool_use_block_roundtrip() {
449        let raw = json!({
450            "type": "tool_use",
451            "id": "toolu_01XyZ",
452            "name": "Edit",
453            "input": {"file_path": "/tmp/foo.rs", "old_string": "a", "new_string": "b"},
454        });
455        let block: ContentBlock = serde_json::from_value(raw.clone()).expect("parse");
456        matches!(block, ContentBlock::ToolUse { .. }).then_some(()).expect("tool_use variant");
457        let re = serde_json::to_value(&block).expect("serialize");
458        assert_eq!(raw, re);
459    }
460
461    #[test]
462    fn tool_result_block_roundtrip() {
463        let raw = json!({
464            "type": "tool_result",
465            "tool_use_id": "toolu_01XyZ",
466            "content": "file edited successfully",
467            "is_error": false,
468        });
469        let block: ContentBlock = serde_json::from_value(raw.clone()).expect("parse");
470        matches!(block, ContentBlock::ToolResult { .. })
471            .then_some(())
472            .expect("tool_result variant");
473        let re = serde_json::to_value(&block).expect("serialize");
474        assert_eq!(raw, re);
475    }
476
477    #[test]
478    fn unknown_block_type_falls_back_to_unknown_variant() {
479        // Forward-compat: unrecognised content block types (e.g. Anthropic
480        // API's `document` block for PDF inputs, or any future block) must
481        // land in ContentBlock::Unknown instead of erroring - real-session
482        // probe uncovered this when a `document` block crashed the parser.
483        let raw = json!({"type": "unknown_kind", "data": "..."});
484        let block: ContentBlock = serde_json::from_value(raw.clone()).expect("parse");
485        let ContentBlock::Unknown { type_str, raw: echoed } = block else {
486            panic!("expected Unknown variant");
487        };
488        assert_eq!(type_str, "unknown_kind");
489        assert_ne!(type_str, "<missing>");
490        assert_eq!(echoed, raw);
491    }
492
493    #[test]
494    fn missing_type_field_lands_in_unknown_with_missing_sentinel() {
495        // Wire corruption (block payload with no `type` key at all) is
496        // distinguishable from forward-compat drift (block with an
497        // unrecognised `type` string) via the explicit `<missing>`
498        // sentinel. Mirrors the same contract for
499        // `ControlRequestKind::Deserialize` in `src/control.rs`. Pinned
500        // here so a future refactor can't silently regress to
501        // `unwrap_or("")`.
502        let raw = json!({"text_field": "no type at all"});
503        let block: ContentBlock = serde_json::from_value(raw.clone()).expect("parse");
504        let ContentBlock::Unknown { type_str, .. } = block else {
505            panic!("expected Unknown variant for missing type");
506        };
507        assert_eq!(
508            type_str, "<missing>",
509            "missing-type must use the explicit sentinel, not empty string"
510        );
511    }
512}