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/// Init system message data - sent at session start
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct InitMessage {
107    /// Session identifier
108    pub session_id: String,
109    /// Current working directory
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub cwd: Option<String>,
112    /// Model being used
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub model: Option<String>,
115    /// List of available tools
116    #[serde(default, skip_serializing_if = "Vec::is_empty")]
117    pub tools: Vec<String>,
118    /// MCP servers configured
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub mcp_servers: Vec<Value>,
121}
122
123/// Status system message - sent during operations like context compaction
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct StatusMessage {
126    /// Session identifier
127    pub session_id: String,
128    /// Current status (e.g., "compacting") or null when complete
129    pub status: Option<String>,
130    /// Unique identifier for this message
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub uuid: Option<String>,
133}
134
135/// Compact boundary message - marks where context compaction occurred
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct CompactBoundaryMessage {
138    /// Session identifier
139    pub session_id: String,
140    /// Metadata about the compaction
141    pub compact_metadata: CompactMetadata,
142    /// Unique identifier for this message
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub uuid: Option<String>,
145}
146
147/// Metadata about context compaction
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct CompactMetadata {
150    /// Number of tokens before compaction
151    pub pre_tokens: u64,
152    /// What triggered the compaction ("auto" or "manual")
153    pub trigger: String,
154}
155
156/// Assistant message
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct AssistantMessage {
159    pub message: AssistantMessageContent,
160    pub session_id: String,
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub uuid: Option<String>,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub parent_tool_use_id: Option<String>,
165}
166
167/// Nested message content for assistant messages
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct AssistantMessageContent {
170    pub id: String,
171    pub role: String,
172    pub model: String,
173    pub content: Vec<ContentBlock>,
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub stop_reason: Option<String>,
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub stop_sequence: Option<String>,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub usage: Option<AssistantUsage>,
180}
181
182/// Usage information for assistant messages
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct AssistantUsage {
185    /// Number of input tokens
186    #[serde(default)]
187    pub input_tokens: u32,
188
189    /// Number of output tokens
190    #[serde(default)]
191    pub output_tokens: u32,
192
193    /// Tokens used to create cache
194    #[serde(default)]
195    pub cache_creation_input_tokens: u32,
196
197    /// Tokens read from cache
198    #[serde(default)]
199    pub cache_read_input_tokens: u32,
200
201    /// Service tier used (e.g., "standard")
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub service_tier: Option<String>,
204
205    /// Detailed cache creation breakdown
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub cache_creation: Option<CacheCreationDetails>,
208}
209
210/// Detailed cache creation information
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct CacheCreationDetails {
213    /// Ephemeral 1-hour input tokens
214    #[serde(default)]
215    pub ephemeral_1h_input_tokens: u32,
216
217    /// Ephemeral 5-minute input tokens
218    #[serde(default)]
219    pub ephemeral_5m_input_tokens: u32,
220}
221
222#[cfg(test)]
223mod tests {
224    use crate::io::ClaudeOutput;
225
226    #[test]
227    fn test_system_message_init() {
228        let json = r#"{
229            "type": "system",
230            "subtype": "init",
231            "session_id": "test-session-123",
232            "cwd": "/home/user/project",
233            "model": "claude-sonnet-4",
234            "tools": ["Bash", "Read", "Write"],
235            "mcp_servers": []
236        }"#;
237
238        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
239        if let ClaudeOutput::System(sys) = output {
240            assert!(sys.is_init());
241            assert!(!sys.is_status());
242            assert!(!sys.is_compact_boundary());
243
244            let init = sys.as_init().expect("Should parse as init");
245            assert_eq!(init.session_id, "test-session-123");
246            assert_eq!(init.cwd, Some("/home/user/project".to_string()));
247            assert_eq!(init.model, Some("claude-sonnet-4".to_string()));
248            assert_eq!(init.tools, vec!["Bash", "Read", "Write"]);
249        } else {
250            panic!("Expected System message");
251        }
252    }
253
254    #[test]
255    fn test_system_message_status() {
256        let json = r#"{
257            "type": "system",
258            "subtype": "status",
259            "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
260            "status": "compacting",
261            "uuid": "32eb9f9d-5ef7-47ff-8fce-bbe22fe7ed93"
262        }"#;
263
264        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
265        if let ClaudeOutput::System(sys) = output {
266            assert!(sys.is_status());
267            assert!(!sys.is_init());
268
269            let status = sys.as_status().expect("Should parse as status");
270            assert_eq!(status.session_id, "879c1a88-3756-4092-aa95-0020c4ed9692");
271            assert_eq!(status.status, Some("compacting".to_string()));
272            assert_eq!(
273                status.uuid,
274                Some("32eb9f9d-5ef7-47ff-8fce-bbe22fe7ed93".to_string())
275            );
276        } else {
277            panic!("Expected System message");
278        }
279    }
280
281    #[test]
282    fn test_system_message_status_null() {
283        let json = r#"{
284            "type": "system",
285            "subtype": "status",
286            "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
287            "status": null,
288            "uuid": "92d9637e-d00e-418e-acd2-a504e3861c6a"
289        }"#;
290
291        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
292        if let ClaudeOutput::System(sys) = output {
293            let status = sys.as_status().expect("Should parse as status");
294            assert_eq!(status.status, None);
295        } else {
296            panic!("Expected System message");
297        }
298    }
299
300    #[test]
301    fn test_system_message_compact_boundary() {
302        let json = r#"{
303            "type": "system",
304            "subtype": "compact_boundary",
305            "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
306            "compact_metadata": {
307                "pre_tokens": 155285,
308                "trigger": "auto"
309            },
310            "uuid": "a67780d5-74cb-48b1-9137-7a6e7cee45d7"
311        }"#;
312
313        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
314        if let ClaudeOutput::System(sys) = output {
315            assert!(sys.is_compact_boundary());
316            assert!(!sys.is_init());
317            assert!(!sys.is_status());
318
319            let compact = sys
320                .as_compact_boundary()
321                .expect("Should parse as compact_boundary");
322            assert_eq!(compact.session_id, "879c1a88-3756-4092-aa95-0020c4ed9692");
323            assert_eq!(compact.compact_metadata.pre_tokens, 155285);
324            assert_eq!(compact.compact_metadata.trigger, "auto");
325        } else {
326            panic!("Expected System message");
327        }
328    }
329}