Skip to main content

claude_session_types/events/
message.rs

1//! Message content types (Level 2)
2//!
3//! Found in `.message.content[]` for both user and assistant events.
4//!
5//! # Content Block Types
6//!
7//! ```text
8//! Content Blocks (.message.content[].type)
9//! ├── text (18,557)          - Text content from user/assistant
10//! ├── tool_use (30,782)      - Tool invocations in assistant messages
11//! ├── tool_result (29,848)   - Tool results in user messages
12//! ├── image (5)              - Image attachments (base64)
13//! └── attachment             - File attachments, hooks, reminders
14//! ```
15//!
16//! # Usage
17//!
18//! Content blocks appear in:
19//! - User messages: `text`, `tool_result`, `image`
20//! - Assistant messages: `text`, `tool_use`
21//! - Progress normalized messages: all types
22
23use serde::{Deserialize, Serialize};
24use serde_json::Value as JsonValue;
25
26/// Message content wrapper
27///
28/// Contains role and content blocks for both user and assistant messages.
29///
30/// # Example
31///
32/// ```json
33/// {
34///   "role": "user",
35///   "content": [
36///     {"type": "text", "text": "Hello"},
37///     {"type": "tool_result", "tool_use_id": "...", "content": "..."}
38///   ]
39/// }
40/// ```
41///
42/// # Wire format drift
43///
44/// Real Claude Code transcripts (v2.1.2xx) put a bare JSON **string** in
45/// `content` for plain single-turn human prompts, not the documented array of
46/// content blocks — the array form is only used once the turn carries
47/// structured pieces (tool results, images, thinking). Both shapes are
48/// accepted: a bare string normalizes to a single [`ContentBlock::Text`].
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct MessageContent {
51    /// Role: "user" or "assistant"
52    pub role: String,
53
54    /// Content blocks (text, tool use, tool results, etc.)
55    ///
56    /// Accepts either the documented array-of-blocks shape or a bare string
57    /// (the common shape for plain-text human prompts on disk).
58    #[serde(deserialize_with = "deserialize_content_blocks")]
59    pub content: Vec<ContentBlock>,
60}
61
62/// Deserialize `content` as either a bare string or an array of content blocks.
63///
64/// A bare string becomes a single `ContentBlock::Text`, matching how the array
65/// form represents the same plain-text turn.
66fn deserialize_content_blocks<'de, D>(deserializer: D) -> Result<Vec<ContentBlock>, D::Error>
67where
68    D: serde::Deserializer<'de>,
69{
70    #[derive(Deserialize)]
71    #[serde(untagged)]
72    enum ContentRepr {
73        Text(String),
74        Blocks(Vec<ContentBlock>),
75    }
76
77    match ContentRepr::deserialize(deserializer)? {
78        ContentRepr::Text(text) => Ok(vec![ContentBlock::Text(TextBlock { text })]),
79        ContentRepr::Blocks(blocks) => Ok(blocks),
80    }
81}
82
83/// Content block discriminator
84///
85/// All possible content block types found in message content arrays.
86/// Uses serde's tagged enum to automatically parse based on `type` field.
87///
88/// # Links
89///
90/// - User messages contain: `Text`, `ToolResult`, `Image`
91/// - Assistant messages contain: `Text`, `ToolUse`, `Thinking` (when extended thinking enabled)
92/// - Progress normalized messages contain: all types including `Attachment`
93///
94/// # Frequency (per large session)
95///
96/// - `ToolUse`: ~30k occurrences
97/// - `ToolResult`: ~30k occurrences
98/// - `Text`: ~19k occurrences
99/// - `Thinking`: Variable (only when extended thinking mode enabled)
100/// - `Image`: ~5 occurrences
101#[derive(Debug, Clone, Serialize, Deserialize)]
102#[serde(tag = "type", rename_all = "snake_case")]
103pub enum ContentBlock {
104    /// Text content block
105    ///
106    /// Found in: user messages, assistant messages
107    ///
108    /// Contains plain text or markdown from user input or assistant responses.
109    Text(TextBlock),
110
111    /// Tool use invocation
112    ///
113    /// Found in: assistant messages
114    ///
115    /// Represents Claude invoking a tool (Read, Write, Edit, Bash, etc.)
116    ToolUse(ToolUseBlock),
117
118    /// Tool execution result
119    ///
120    /// Found in: user messages (as tool result feedback)
121    ///
122    /// Contains the output from a tool execution, sent back to Claude.
123    ToolResult(ToolResultBlock),
124
125    /// Image attachment
126    ///
127    /// Found in: user messages
128    ///
129    /// Base64-encoded image data sent by user.
130    Image(ImageBlock),
131
132    /// Attachment (files, hooks, reminders)
133    ///
134    /// Found in: progress normalized messages
135    ///
136    /// See `attachment` module for detailed attachment types.
137    #[serde(rename = "attachment")]
138    Attachment(crate::events::attachment::AttachmentBlock),
139
140    /// Thinking block (extended thinking mode)
141    ///
142    /// Found in: assistant messages (only when user enables extended thinking)
143    ///
144    /// Contains Claude's reasoning process with signature verification.
145    /// This is OPTIONAL - only present when extended thinking mode is enabled.
146    Thinking(ThinkingBlock),
147
148    /// Unknown content block type (forward compatibility)
149    #[serde(other)]
150    Unknown,
151}
152
153impl ContentBlock {
154    /// Extract text if this is a text block
155    #[must_use]
156    pub fn as_text(&self) -> Option<&str> {
157        match self {
158            Self::Text(text) => Some(&text.text),
159            _ => None,
160        }
161    }
162
163    /// Extract tool use if this is a tool use block
164    #[must_use]
165    pub fn as_tool_use(&self) -> Option<(&str, &str, &JsonValue)> {
166        match self {
167            Self::ToolUse(tool) => Some((&tool.id, &tool.name, &tool.input)),
168            _ => None,
169        }
170    }
171
172    /// Extract thinking if this is a thinking block
173    #[must_use]
174    pub fn as_thinking(&self) -> Option<&str> {
175        match self {
176            Self::Thinking(thinking) => Some(&thinking.thinking),
177            _ => None,
178        }
179    }
180
181    /// Check if this is a tool result
182    #[must_use]
183    pub fn is_tool_result(&self) -> bool {
184        matches!(self, Self::ToolResult(_))
185    }
186
187    /// Check if this is an attachment
188    #[must_use]
189    pub fn is_attachment(&self) -> bool {
190        matches!(self, Self::Attachment(_))
191    }
192
193    /// Check if this is a thinking block
194    #[must_use]
195    pub fn is_thinking(&self) -> bool {
196        matches!(self, Self::Thinking(_))
197    }
198}
199
200/// Text content block
201///
202/// Contains plain text or markdown from user input or assistant responses.
203///
204/// # Example
205///
206/// ```json
207/// {
208///   "type": "text",
209///   "text": "I'll help you implement that feature."
210/// }
211/// ```
212///
213/// # Usage
214///
215/// - User prompts
216/// - Assistant explanations
217/// - Agent task descriptions
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct TextBlock {
220    /// Text content (plain text or markdown)
221    pub text: String,
222}
223
224/// Tool use invocation block
225///
226/// Represents Claude invoking a tool (Read, Write, Edit, Bash, etc.)
227///
228/// # Example
229///
230/// ```json
231/// {
232///   "type": "tool_use",
233///   "id": "toolu_abc123",
234///   "name": "Read",
235///   "input": {
236///     "file_path": "/path/to/file.rs"
237///   }
238/// }
239/// ```
240///
241/// # Links
242///
243/// - `id` links to `ToolResultBlock.tool_use_id` in user messages
244/// - `id` links to `ProgressEvent.tool_use_id` for progress updates
245///
246/// # Common Tool Names
247///
248/// - `Read` - Read file contents
249/// - `Write` - Write new file
250/// - `Edit` - Edit existing file
251/// - `Bash` - Execute bash command
252/// - `Glob` - Search for files by pattern
253/// - `Grep` - Search file contents
254/// - `TodoWrite` - Update todo list
255/// - `Agent` - Spawn sub-agent
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct ToolUseBlock {
258    /// Unique tool use ID
259    ///
260    /// Links to tool result in user message
261    /// Format: `"toolu_{random}"` or `"{tool_name}-{uuid}"`
262    pub id: String,
263
264    /// Tool name
265    ///
266    /// Common tools: Read, Write, Edit, Bash, Glob, Grep, Agent
267    pub name: String,
268
269    /// Tool input parameters (JSON object)
270    ///
271    /// Structure depends on tool type:
272    /// - Read: `{"file_path": "/path"}`
273    /// - Write: `{"file_path": "/path", "content": "..."}`
274    /// - Edit: `{"file_path": "/path", "old_string": "...", "new_string": "..."}`
275    /// - Bash: `{"command": "cargo build", "description": "..."}`
276    pub input: JsonValue,
277}
278
279/// Tool execution result block
280///
281/// Contains the output from a tool execution, sent back to Claude.
282///
283/// # Example
284///
285/// ```json
286/// {
287///   "type": "tool_result",
288///   "tool_use_id": "toolu_abc123",
289///   "content": {
290///     "type": "text",
291///     "content": "File contents here..."
292///   }
293/// }
294/// ```
295///
296/// # Links
297///
298/// - `tool_use_id` links back to `ToolUseBlock.id` in assistant message
299/// - Nested `content` can contain `ToolUseResult` for file operations
300///
301/// # Content Types
302///
303/// Content can be:
304/// - String: simple text output
305/// - Object with `type` field: structured result (see `tool_result` module)
306/// - Array: multiple result items
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct ToolResultBlock {
309    /// Links back to tool use that generated this result
310    #[serde(rename = "tool_use_id")]
311    pub tool_use_id: String,
312
313    /// Tool execution result (JSON value)
314    ///
315    /// Can be:
316    /// - String: simple output (`"cargo build completed"`)
317    /// - Object: structured result with `type` field (see `ToolUseResult`)
318    /// - Array: multiple result items
319    pub content: JsonValue,
320
321    /// Structured tool use result (for file operations)
322    ///
323    /// Present when tool modifies files (Write, Edit). Deserialized leniently:
324    /// a `type` tag that no known `ToolUseResult` shape matches (e.g. a
325    /// `"text"`-tagged result missing the plain `content` field) becomes
326    /// `None` instead of failing the whole enclosing event — see
327    /// [`crate::events::tool_result::deserialize_tool_use_result_lenient`].
328    #[serde(
329        rename = "toolUseResult",
330        deserialize_with = "crate::events::tool_result::deserialize_tool_use_result_lenient",
331        default
332    )]
333    pub tool_use_result: Option<crate::events::tool_result::ToolUseResult>,
334}
335
336/// Thinking block (extended thinking mode)
337///
338/// Contains Claude's reasoning process when extended thinking mode is enabled.
339/// This block is OPTIONAL and only appears when the user explicitly enables
340/// extended thinking in their Claude settings.
341///
342/// # Example
343///
344/// ```json
345/// {
346///   "type": "thinking",
347///   "thinking": "The user is asking about...",
348///   "signature": "ErQYCkYICxgCKkA1FuCoAqSF..."
349/// }
350/// ```
351///
352/// # Usage
353///
354/// - Available when extended thinking mode is enabled
355/// - Contains full reasoning process
356/// - Includes cryptographic signature for verification
357/// - Makes Claude competitive with Gemini for reasoning extraction
358///
359/// # Frequency
360///
361/// Variable - only present when extended thinking is enabled by user
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct ThinkingBlock {
364    /// Full reasoning text
365    ///
366    /// Contains Claude's complete thought process, including:
367    /// - Analysis of the request
368    /// - Consideration of alternatives
369    /// - Decision-making rationale
370    /// - Context evaluation
371    pub thinking: String,
372
373    /// Cryptographic signature for verification
374    ///
375    /// Used to verify the authenticity of the thinking content.
376    /// Optional field that may not be present in all thinking blocks.
377    pub signature: Option<String>,
378}
379
380/// Image attachment block
381///
382/// Base64-encoded image data sent by user.
383///
384/// # Example
385///
386/// ```json
387/// {
388///   "type": "image",
389///   "source": {
390///     "type": "base64",
391///     "media_type": "image/png",
392///     "data": "iVBORw0KGgoAAAANSUhEUgAA..."
393///   }
394/// }
395/// ```
396///
397/// # Usage
398///
399/// - User sends screenshot
400/// - User sends diagram for analysis
401/// - User sends error message screenshot
402///
403/// # Frequency
404///
405/// Very rare in typical sessions (~5 per large session)
406#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct ImageBlock {
408    /// Image source (base64 data)
409    pub source: ImageSource,
410}
411
412/// Image source (base64 encoded)
413///
414/// Contains base64-encoded image data and media type.
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct ImageSource {
417    /// Source type (always "base64")
418    #[serde(rename = "type")]
419    pub source_type: String,
420
421    /// Media type (MIME type)
422    ///
423    /// Examples: `"image/png"`, `"image/jpeg"`, `"image/webp"`
424    pub media_type: String,
425
426    /// Base64-encoded image data
427    pub data: String,
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn test_parse_text_block() {
436        let json = r#"{
437            "type": "text",
438            "text": "Hello world"
439        }"#;
440
441        let block: ContentBlock = serde_json::from_str(json).unwrap();
442        assert!(matches!(block, ContentBlock::Text(_)));
443
444        if let ContentBlock::Text(text) = block {
445            assert_eq!(text.text, "Hello world");
446        }
447    }
448
449    #[test]
450    fn test_parse_tool_use_block() {
451        let json = r#"{
452            "type": "tool_use",
453            "id": "toolu_abc123",
454            "name": "Read",
455            "input": {
456                "file_path": "/test/file.rs"
457            }
458        }"#;
459
460        let block: ContentBlock = serde_json::from_str(json).unwrap();
461        assert!(matches!(block, ContentBlock::ToolUse(_)));
462
463        if let ContentBlock::ToolUse(tool) = block {
464            assert_eq!(tool.id, "toolu_abc123");
465            assert_eq!(tool.name, "Read");
466            assert_eq!(tool.input["file_path"], "/test/file.rs");
467        }
468    }
469
470    #[test]
471    fn test_parse_tool_result_block() {
472        let json = r#"{
473            "type": "tool_result",
474            "tool_use_id": "toolu_abc123",
475            "content": "File contents here"
476        }"#;
477
478        let block: ContentBlock = serde_json::from_str(json).unwrap();
479        assert!(matches!(block, ContentBlock::ToolResult(_)));
480        assert!(block.is_tool_result());
481
482        if let ContentBlock::ToolResult(result) = block {
483            assert_eq!(result.tool_use_id, "toolu_abc123");
484            assert_eq!(result.content, "File contents here");
485        }
486    }
487
488    #[test]
489    fn test_parse_image_block() {
490        let json = r#"{
491            "type": "image",
492            "source": {
493                "type": "base64",
494                "media_type": "image/png",
495                "data": "iVBORw0KGgo="
496            }
497        }"#;
498
499        let block: ContentBlock = serde_json::from_str(json).unwrap();
500        assert!(matches!(block, ContentBlock::Image(_)));
501
502        if let ContentBlock::Image(image) = block {
503            assert_eq!(image.source.source_type, "base64");
504            assert_eq!(image.source.media_type, "image/png");
505            assert_eq!(image.source.data, "iVBORw0KGgo=");
506        }
507    }
508
509    #[test]
510    fn test_content_block_helpers() {
511        let text_block = ContentBlock::Text(TextBlock {
512            text: "Test".to_string(),
513        });
514        assert_eq!(text_block.as_text(), Some("Test"));
515        assert!(!text_block.is_tool_result());
516
517        let tool_block = ContentBlock::ToolUse(ToolUseBlock {
518            id: "tool-1".to_string(),
519            name: "Read".to_string(),
520            input: serde_json::json!({}),
521        });
522        let tool_use = tool_block.as_tool_use();
523        assert!(tool_use.is_some());
524        let (id, name, _) = tool_use.unwrap();
525        assert_eq!(id, "tool-1");
526        assert_eq!(name, "Read");
527    }
528
529    #[test]
530    fn test_parse_thinking_block() {
531        let json = r#"{
532            "type": "thinking",
533            "thinking": "Let me analyze this request carefully...",
534            "signature": "ErQYCkYICxgCKkA1FuCoAqSF..."
535        }"#;
536
537        let block: ContentBlock = serde_json::from_str(json).unwrap();
538        assert!(matches!(block, ContentBlock::Thinking(_)));
539        assert!(block.is_thinking());
540
541        if let ContentBlock::Thinking(thinking) = block {
542            assert_eq!(thinking.thinking, "Let me analyze this request carefully...");
543            assert!(thinking.signature.is_some());
544            assert_eq!(thinking.signature.unwrap(), "ErQYCkYICxgCKkA1FuCoAqSF...");
545        }
546    }
547
548    #[test]
549    fn test_parse_thinking_block_without_signature() {
550        let json = r#"{
551            "type": "thinking",
552            "thinking": "Analyzing the problem..."
553        }"#;
554
555        let block: ContentBlock = serde_json::from_str(json).unwrap();
556        assert!(matches!(block, ContentBlock::Thinking(_)));
557
558        if let ContentBlock::Thinking(thinking) = block {
559            assert_eq!(thinking.thinking, "Analyzing the problem...");
560            assert!(thinking.signature.is_none());
561        }
562    }
563
564    #[test]
565    fn test_content_block_as_thinking() {
566        let thinking_block = ContentBlock::Thinking(ThinkingBlock {
567            thinking: "Test reasoning".to_string(),
568            signature: Some("sig123".to_string()),
569        });
570
571        assert_eq!(thinking_block.as_thinking(), Some("Test reasoning"));
572        assert!(thinking_block.is_thinking());
573
574        let text_block = ContentBlock::Text(TextBlock {
575            text: "Test".to_string(),
576        });
577        assert_eq!(text_block.as_thinking(), None);
578        assert!(!text_block.is_thinking());
579    }
580
581    #[test]
582    fn test_message_content() {
583        let json = r#"{
584            "role": "assistant",
585            "content": [
586                {
587                    "type": "text",
588                    "text": "Let me read the file"
589                },
590                {
591                    "type": "tool_use",
592                    "id": "tool-123",
593                    "name": "Read",
594                    "input": {"file_path": "/test.rs"}
595                }
596            ]
597        }"#;
598
599        let message: MessageContent = serde_json::from_str(json).unwrap();
600        assert_eq!(message.role, "assistant");
601        assert_eq!(message.content.len(), 2);
602
603        assert!(matches!(message.content[0], ContentBlock::Text(_)));
604        assert!(matches!(message.content[1], ContentBlock::ToolUse(_)));
605    }
606
607    #[test]
608    fn test_message_with_thinking() {
609        let json = r#"{
610            "role": "assistant",
611            "content": [
612                {
613                    "type": "thinking",
614                    "thinking": "I need to analyze this carefully..."
615                },
616                {
617                    "type": "text",
618                    "text": "Based on my analysis..."
619                }
620            ]
621        }"#;
622
623        let message: MessageContent = serde_json::from_str(json).unwrap();
624        assert_eq!(message.role, "assistant");
625        assert_eq!(message.content.len(), 2);
626
627        assert!(matches!(message.content[0], ContentBlock::Thinking(_)));
628        assert!(matches!(message.content[1], ContentBlock::Text(_)));
629
630        // Verify we can extract thinking
631        assert!(message.content[0].is_thinking());
632        assert_eq!(
633            message.content[0].as_thinking(),
634            Some("I need to analyze this carefully...")
635        );
636    }
637}