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}