Skip to main content

claude_codes/io/
message_types.rs

1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use serde_json::Value;
3use uuid::Uuid;
4
5use super::content_blocks::{deserialize_content_blocks, ContentBlock};
6
7/// Serialize an optional UUID as a string
8pub(crate) fn serialize_optional_uuid<S>(
9    uuid: &Option<Uuid>,
10    serializer: S,
11) -> Result<S::Ok, S::Error>
12where
13    S: Serializer,
14{
15    match uuid {
16        Some(id) => serializer.serialize_str(&id.to_string()),
17        None => serializer.serialize_none(),
18    }
19}
20
21/// Deserialize an optional UUID from a string
22pub(crate) fn deserialize_optional_uuid<'de, D>(deserializer: D) -> Result<Option<Uuid>, D::Error>
23where
24    D: Deserializer<'de>,
25{
26    let opt_str: Option<String> = Option::deserialize(deserializer)?;
27    match opt_str {
28        Some(s) => Uuid::parse_str(&s)
29            .map(Some)
30            .map_err(serde::de::Error::custom),
31        None => Ok(None),
32    }
33}
34
35/// User message
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct UserMessage {
38    pub message: MessageContent,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    #[serde(
41        serialize_with = "serialize_optional_uuid",
42        deserialize_with = "deserialize_optional_uuid"
43    )]
44    pub session_id: Option<Uuid>,
45}
46
47/// Message content with role
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct MessageContent {
50    pub role: String,
51    #[serde(deserialize_with = "deserialize_content_blocks")]
52    pub content: Vec<ContentBlock>,
53}
54
55/// System message with metadata
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct SystemMessage {
58    pub subtype: String,
59    #[serde(flatten)]
60    pub data: Value, // Captures all other fields
61}
62
63impl SystemMessage {
64    /// Check if this is an init message
65    pub fn is_init(&self) -> bool {
66        self.subtype == "init"
67    }
68
69    /// Check if this is a status message
70    pub fn is_status(&self) -> bool {
71        self.subtype == "status"
72    }
73
74    /// Check if this is a compact_boundary message
75    pub fn is_compact_boundary(&self) -> bool {
76        self.subtype == "compact_boundary"
77    }
78
79    /// Try to parse as an init message
80    pub fn as_init(&self) -> Option<InitMessage> {
81        if self.subtype != "init" {
82            return None;
83        }
84        serde_json::from_value(self.data.clone()).ok()
85    }
86
87    /// Try to parse as a status message
88    pub fn as_status(&self) -> Option<StatusMessage> {
89        if self.subtype != "status" {
90            return None;
91        }
92        serde_json::from_value(self.data.clone()).ok()
93    }
94
95    /// Try to parse as a compact_boundary message
96    pub fn as_compact_boundary(&self) -> Option<CompactBoundaryMessage> {
97        if self.subtype != "compact_boundary" {
98            return None;
99        }
100        serde_json::from_value(self.data.clone()).ok()
101    }
102}
103
104/// Plugin info from the init message
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct PluginInfo {
107    /// Plugin name
108    pub name: String,
109    /// Path to the plugin on disk
110    pub path: String,
111}
112
113/// Init system message data - sent at session start
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct InitMessage {
116    /// Session identifier
117    pub session_id: String,
118    /// Current working directory
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub cwd: Option<String>,
121    /// Model being used
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub model: Option<String>,
124    /// List of available tools
125    #[serde(default, skip_serializing_if = "Vec::is_empty")]
126    pub tools: Vec<String>,
127    /// MCP servers configured
128    #[serde(default, skip_serializing_if = "Vec::is_empty")]
129    pub mcp_servers: Vec<Value>,
130    /// Available slash commands (e.g., "compact", "cost", "review")
131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
132    pub slash_commands: Vec<String>,
133    /// Available agent types (e.g., "Bash", "Explore", "Plan")
134    #[serde(default, skip_serializing_if = "Vec::is_empty")]
135    pub agents: Vec<String>,
136    /// Installed plugins
137    #[serde(default, skip_serializing_if = "Vec::is_empty")]
138    pub plugins: Vec<PluginInfo>,
139    /// Installed skills
140    #[serde(default, skip_serializing_if = "Vec::is_empty")]
141    pub skills: Vec<Value>,
142    /// Claude Code CLI version
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub claude_code_version: Option<String>,
145    /// How the API key was sourced (e.g., "none")
146    #[serde(skip_serializing_if = "Option::is_none", rename = "apiKeySource")]
147    pub api_key_source: Option<String>,
148    /// Output style (e.g., "default")
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub output_style: Option<String>,
151    /// Permission mode (e.g., "default")
152    #[serde(skip_serializing_if = "Option::is_none", rename = "permissionMode")]
153    pub permission_mode: Option<String>,
154}
155
156/// Status system message - sent during operations like context compaction
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct StatusMessage {
159    /// Session identifier
160    pub session_id: String,
161    /// Current status (e.g., "compacting") or null when complete
162    pub status: Option<String>,
163    /// Unique identifier for this message
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub uuid: Option<String>,
166}
167
168/// Compact boundary message - marks where context compaction occurred
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct CompactBoundaryMessage {
171    /// Session identifier
172    pub session_id: String,
173    /// Metadata about the compaction
174    pub compact_metadata: CompactMetadata,
175    /// Unique identifier for this message
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub uuid: Option<String>,
178}
179
180/// Metadata about context compaction
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct CompactMetadata {
183    /// Number of tokens before compaction
184    pub pre_tokens: u64,
185    /// What triggered the compaction ("auto" or "manual")
186    pub trigger: String,
187}
188
189/// Assistant message
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct AssistantMessage {
192    pub message: AssistantMessageContent,
193    pub session_id: String,
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub uuid: Option<String>,
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub parent_tool_use_id: Option<String>,
198}
199
200/// Nested message content for assistant messages
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct AssistantMessageContent {
203    pub id: String,
204    pub role: String,
205    pub model: String,
206    pub content: Vec<ContentBlock>,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub stop_reason: Option<String>,
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub stop_sequence: Option<String>,
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub usage: Option<AssistantUsage>,
213}
214
215/// Usage information for assistant messages
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct AssistantUsage {
218    /// Number of input tokens
219    #[serde(default)]
220    pub input_tokens: u32,
221
222    /// Number of output tokens
223    #[serde(default)]
224    pub output_tokens: u32,
225
226    /// Tokens used to create cache
227    #[serde(default)]
228    pub cache_creation_input_tokens: u32,
229
230    /// Tokens read from cache
231    #[serde(default)]
232    pub cache_read_input_tokens: u32,
233
234    /// Service tier used (e.g., "standard")
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub service_tier: Option<String>,
237
238    /// Detailed cache creation breakdown
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub cache_creation: Option<CacheCreationDetails>,
241}
242
243/// Detailed cache creation information
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct CacheCreationDetails {
246    /// Ephemeral 1-hour input tokens
247    #[serde(default)]
248    pub ephemeral_1h_input_tokens: u32,
249
250    /// Ephemeral 5-minute input tokens
251    #[serde(default)]
252    pub ephemeral_5m_input_tokens: u32,
253}
254
255#[cfg(test)]
256mod tests {
257    use crate::io::ClaudeOutput;
258
259    #[test]
260    fn test_system_message_init() {
261        let json = r#"{
262            "type": "system",
263            "subtype": "init",
264            "session_id": "test-session-123",
265            "cwd": "/home/user/project",
266            "model": "claude-sonnet-4",
267            "tools": ["Bash", "Read", "Write"],
268            "mcp_servers": [],
269            "slash_commands": ["compact", "cost", "review"],
270            "agents": ["Bash", "Explore", "Plan"],
271            "plugins": [{"name": "rust-analyzer-lsp", "path": "/home/user/.claude/plugins/rust-analyzer-lsp/1.0.0"}],
272            "skills": [],
273            "claude_code_version": "2.1.15",
274            "apiKeySource": "none",
275            "output_style": "default",
276            "permissionMode": "default"
277        }"#;
278
279        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
280        if let ClaudeOutput::System(sys) = output {
281            assert!(sys.is_init());
282            assert!(!sys.is_status());
283            assert!(!sys.is_compact_boundary());
284
285            let init = sys.as_init().expect("Should parse as init");
286            assert_eq!(init.session_id, "test-session-123");
287            assert_eq!(init.cwd, Some("/home/user/project".to_string()));
288            assert_eq!(init.model, Some("claude-sonnet-4".to_string()));
289            assert_eq!(init.tools, vec!["Bash", "Read", "Write"]);
290            assert_eq!(init.slash_commands, vec!["compact", "cost", "review"]);
291            assert_eq!(init.agents, vec!["Bash", "Explore", "Plan"]);
292            assert_eq!(init.plugins.len(), 1);
293            assert_eq!(init.plugins[0].name, "rust-analyzer-lsp");
294            assert_eq!(init.claude_code_version, Some("2.1.15".to_string()));
295            assert_eq!(init.api_key_source, Some("none".to_string()));
296            assert_eq!(init.output_style, Some("default".to_string()));
297            assert_eq!(init.permission_mode, Some("default".to_string()));
298        } else {
299            panic!("Expected System message");
300        }
301    }
302
303    #[test]
304    fn test_system_message_init_from_real_capture() {
305        let json = include_str!("../../test_cases/tool_use_captures/tool_msg_0.json");
306        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
307        if let ClaudeOutput::System(sys) = output {
308            let init = sys.as_init().expect("Should parse real init capture");
309            assert_eq!(init.slash_commands.len(), 8);
310            assert!(init.slash_commands.contains(&"compact".to_string()));
311            assert!(init.slash_commands.contains(&"review".to_string()));
312            assert_eq!(init.agents.len(), 5);
313            assert!(init.agents.contains(&"Bash".to_string()));
314            assert!(init.agents.contains(&"Explore".to_string()));
315            assert_eq!(init.plugins.len(), 1);
316            assert_eq!(init.plugins[0].name, "rust-analyzer-lsp");
317            assert_eq!(init.claude_code_version, Some("2.1.15".to_string()));
318        } else {
319            panic!("Expected System message");
320        }
321    }
322
323    #[test]
324    fn test_system_message_status() {
325        let json = r#"{
326            "type": "system",
327            "subtype": "status",
328            "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
329            "status": "compacting",
330            "uuid": "32eb9f9d-5ef7-47ff-8fce-bbe22fe7ed93"
331        }"#;
332
333        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
334        if let ClaudeOutput::System(sys) = output {
335            assert!(sys.is_status());
336            assert!(!sys.is_init());
337
338            let status = sys.as_status().expect("Should parse as status");
339            assert_eq!(status.session_id, "879c1a88-3756-4092-aa95-0020c4ed9692");
340            assert_eq!(status.status, Some("compacting".to_string()));
341            assert_eq!(
342                status.uuid,
343                Some("32eb9f9d-5ef7-47ff-8fce-bbe22fe7ed93".to_string())
344            );
345        } else {
346            panic!("Expected System message");
347        }
348    }
349
350    #[test]
351    fn test_system_message_status_null() {
352        let json = r#"{
353            "type": "system",
354            "subtype": "status",
355            "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
356            "status": null,
357            "uuid": "92d9637e-d00e-418e-acd2-a504e3861c6a"
358        }"#;
359
360        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
361        if let ClaudeOutput::System(sys) = output {
362            let status = sys.as_status().expect("Should parse as status");
363            assert_eq!(status.status, None);
364        } else {
365            panic!("Expected System message");
366        }
367    }
368
369    #[test]
370    fn test_system_message_compact_boundary() {
371        let json = r#"{
372            "type": "system",
373            "subtype": "compact_boundary",
374            "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
375            "compact_metadata": {
376                "pre_tokens": 155285,
377                "trigger": "auto"
378            },
379            "uuid": "a67780d5-74cb-48b1-9137-7a6e7cee45d7"
380        }"#;
381
382        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
383        if let ClaudeOutput::System(sys) = output {
384            assert!(sys.is_compact_boundary());
385            assert!(!sys.is_init());
386            assert!(!sys.is_status());
387
388            let compact = sys
389                .as_compact_boundary()
390                .expect("Should parse as compact_boundary");
391            assert_eq!(compact.session_id, "879c1a88-3756-4092-aa95-0020c4ed9692");
392            assert_eq!(compact.compact_metadata.pre_tokens, 155285);
393            assert_eq!(compact.compact_metadata.trigger, "auto");
394        } else {
395            panic!("Expected System message");
396        }
397    }
398}