1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use serde_json::Value;
3use uuid::Uuid;
4
5use super::content_blocks::{deserialize_content_blocks, ContentBlock};
6
7pub(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
21pub(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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct SystemMessage {
58 pub subtype: String,
59 #[serde(flatten)]
60 pub data: Value, }
62
63impl SystemMessage {
64 pub fn is_init(&self) -> bool {
66 self.subtype == "init"
67 }
68
69 pub fn is_status(&self) -> bool {
71 self.subtype == "status"
72 }
73
74 pub fn is_compact_boundary(&self) -> bool {
76 self.subtype == "compact_boundary"
77 }
78
79 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 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 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 pub fn is_task_started(&self) -> bool {
105 self.subtype == "task_started"
106 }
107
108 pub fn is_task_progress(&self) -> bool {
110 self.subtype == "task_progress"
111 }
112
113 pub fn is_task_notification(&self) -> bool {
115 self.subtype == "task_notification"
116 }
117
118 pub fn as_task_started(&self) -> Option<TaskStartedMessage> {
120 if self.subtype != "task_started" {
121 return None;
122 }
123 serde_json::from_value(self.data.clone()).ok()
124 }
125
126 pub fn as_task_progress(&self) -> Option<TaskProgressMessage> {
128 if self.subtype != "task_progress" {
129 return None;
130 }
131 serde_json::from_value(self.data.clone()).ok()
132 }
133
134 pub fn as_task_notification(&self) -> Option<TaskNotificationMessage> {
136 if self.subtype != "task_notification" {
137 return None;
138 }
139 serde_json::from_value(self.data.clone()).ok()
140 }
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct PluginInfo {
146 pub name: String,
148 pub path: String,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct InitMessage {
155 pub session_id: String,
157 #[serde(skip_serializing_if = "Option::is_none")]
159 pub cwd: Option<String>,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub model: Option<String>,
163 #[serde(default, skip_serializing_if = "Vec::is_empty")]
165 pub tools: Vec<String>,
166 #[serde(default, skip_serializing_if = "Vec::is_empty")]
168 pub mcp_servers: Vec<Value>,
169 #[serde(default, skip_serializing_if = "Vec::is_empty")]
171 pub slash_commands: Vec<String>,
172 #[serde(default, skip_serializing_if = "Vec::is_empty")]
174 pub agents: Vec<String>,
175 #[serde(default, skip_serializing_if = "Vec::is_empty")]
177 pub plugins: Vec<PluginInfo>,
178 #[serde(default, skip_serializing_if = "Vec::is_empty")]
180 pub skills: Vec<Value>,
181 #[serde(skip_serializing_if = "Option::is_none")]
183 pub claude_code_version: Option<String>,
184 #[serde(skip_serializing_if = "Option::is_none", rename = "apiKeySource")]
186 pub api_key_source: Option<String>,
187 #[serde(skip_serializing_if = "Option::is_none")]
189 pub output_style: Option<String>,
190 #[serde(skip_serializing_if = "Option::is_none", rename = "permissionMode")]
192 pub permission_mode: Option<String>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct StatusMessage {
198 pub session_id: String,
200 pub status: Option<String>,
202 #[serde(skip_serializing_if = "Option::is_none")]
204 pub uuid: Option<String>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct CompactBoundaryMessage {
210 pub session_id: String,
212 pub compact_metadata: CompactMetadata,
214 #[serde(skip_serializing_if = "Option::is_none")]
216 pub uuid: Option<String>,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct CompactMetadata {
222 pub pre_tokens: u64,
224 pub trigger: String,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct TaskUsage {
235 pub duration_ms: u64,
237 pub tool_uses: u64,
239 pub total_tokens: u64,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(rename_all = "snake_case")]
246pub enum TaskType {
247 LocalAgent,
249 LocalBash,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "snake_case")]
256pub enum TaskStatus {
257 Completed,
258 Failed,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct TaskStartedMessage {
264 pub session_id: String,
265 pub task_id: String,
266 pub task_type: TaskType,
267 pub tool_use_id: String,
268 pub description: String,
269 pub uuid: String,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct TaskProgressMessage {
276 pub session_id: String,
277 pub task_id: String,
278 pub tool_use_id: String,
279 pub description: String,
280 pub last_tool_name: String,
281 pub usage: TaskUsage,
282 pub uuid: String,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct TaskNotificationMessage {
289 pub session_id: String,
290 pub task_id: String,
291 pub status: TaskStatus,
292 pub summary: String,
293 pub output_file: Option<String>,
294 #[serde(skip_serializing_if = "Option::is_none")]
295 pub tool_use_id: Option<String>,
296 #[serde(skip_serializing_if = "Option::is_none")]
297 pub usage: Option<TaskUsage>,
298 #[serde(skip_serializing_if = "Option::is_none")]
299 pub uuid: Option<String>,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct AssistantMessage {
305 pub message: AssistantMessageContent,
306 pub session_id: String,
307 #[serde(skip_serializing_if = "Option::is_none")]
308 pub uuid: Option<String>,
309 #[serde(skip_serializing_if = "Option::is_none")]
310 pub parent_tool_use_id: Option<String>,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct AssistantMessageContent {
316 pub id: String,
317 pub role: String,
318 pub model: String,
319 pub content: Vec<ContentBlock>,
320 #[serde(skip_serializing_if = "Option::is_none")]
321 pub stop_reason: Option<String>,
322 #[serde(skip_serializing_if = "Option::is_none")]
323 pub stop_sequence: Option<String>,
324 #[serde(skip_serializing_if = "Option::is_none")]
325 pub usage: Option<AssistantUsage>,
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct AssistantUsage {
331 #[serde(default)]
333 pub input_tokens: u32,
334
335 #[serde(default)]
337 pub output_tokens: u32,
338
339 #[serde(default)]
341 pub cache_creation_input_tokens: u32,
342
343 #[serde(default)]
345 pub cache_read_input_tokens: u32,
346
347 #[serde(skip_serializing_if = "Option::is_none")]
349 pub service_tier: Option<String>,
350
351 #[serde(skip_serializing_if = "Option::is_none")]
353 pub cache_creation: Option<CacheCreationDetails>,
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct CacheCreationDetails {
359 #[serde(default)]
361 pub ephemeral_1h_input_tokens: u32,
362
363 #[serde(default)]
365 pub ephemeral_5m_input_tokens: u32,
366}
367
368#[cfg(test)]
369mod tests {
370 use crate::io::ClaudeOutput;
371
372 #[test]
373 fn test_system_message_init() {
374 let json = r#"{
375 "type": "system",
376 "subtype": "init",
377 "session_id": "test-session-123",
378 "cwd": "/home/user/project",
379 "model": "claude-sonnet-4",
380 "tools": ["Bash", "Read", "Write"],
381 "mcp_servers": [],
382 "slash_commands": ["compact", "cost", "review"],
383 "agents": ["Bash", "Explore", "Plan"],
384 "plugins": [{"name": "rust-analyzer-lsp", "path": "/home/user/.claude/plugins/rust-analyzer-lsp/1.0.0"}],
385 "skills": [],
386 "claude_code_version": "2.1.15",
387 "apiKeySource": "none",
388 "output_style": "default",
389 "permissionMode": "default"
390 }"#;
391
392 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
393 if let ClaudeOutput::System(sys) = output {
394 assert!(sys.is_init());
395 assert!(!sys.is_status());
396 assert!(!sys.is_compact_boundary());
397
398 let init = sys.as_init().expect("Should parse as init");
399 assert_eq!(init.session_id, "test-session-123");
400 assert_eq!(init.cwd, Some("/home/user/project".to_string()));
401 assert_eq!(init.model, Some("claude-sonnet-4".to_string()));
402 assert_eq!(init.tools, vec!["Bash", "Read", "Write"]);
403 assert_eq!(init.slash_commands, vec!["compact", "cost", "review"]);
404 assert_eq!(init.agents, vec!["Bash", "Explore", "Plan"]);
405 assert_eq!(init.plugins.len(), 1);
406 assert_eq!(init.plugins[0].name, "rust-analyzer-lsp");
407 assert_eq!(init.claude_code_version, Some("2.1.15".to_string()));
408 assert_eq!(init.api_key_source, Some("none".to_string()));
409 assert_eq!(init.output_style, Some("default".to_string()));
410 assert_eq!(init.permission_mode, Some("default".to_string()));
411 } else {
412 panic!("Expected System message");
413 }
414 }
415
416 #[test]
417 fn test_system_message_init_from_real_capture() {
418 let json = include_str!("../../test_cases/tool_use_captures/tool_msg_0.json");
419 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
420 if let ClaudeOutput::System(sys) = output {
421 let init = sys.as_init().expect("Should parse real init capture");
422 assert_eq!(init.slash_commands.len(), 8);
423 assert!(init.slash_commands.contains(&"compact".to_string()));
424 assert!(init.slash_commands.contains(&"review".to_string()));
425 assert_eq!(init.agents.len(), 5);
426 assert!(init.agents.contains(&"Bash".to_string()));
427 assert!(init.agents.contains(&"Explore".to_string()));
428 assert_eq!(init.plugins.len(), 1);
429 assert_eq!(init.plugins[0].name, "rust-analyzer-lsp");
430 assert_eq!(init.claude_code_version, Some("2.1.15".to_string()));
431 } else {
432 panic!("Expected System message");
433 }
434 }
435
436 #[test]
437 fn test_system_message_status() {
438 let json = r#"{
439 "type": "system",
440 "subtype": "status",
441 "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
442 "status": "compacting",
443 "uuid": "32eb9f9d-5ef7-47ff-8fce-bbe22fe7ed93"
444 }"#;
445
446 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
447 if let ClaudeOutput::System(sys) = output {
448 assert!(sys.is_status());
449 assert!(!sys.is_init());
450
451 let status = sys.as_status().expect("Should parse as status");
452 assert_eq!(status.session_id, "879c1a88-3756-4092-aa95-0020c4ed9692");
453 assert_eq!(status.status, Some("compacting".to_string()));
454 assert_eq!(
455 status.uuid,
456 Some("32eb9f9d-5ef7-47ff-8fce-bbe22fe7ed93".to_string())
457 );
458 } else {
459 panic!("Expected System message");
460 }
461 }
462
463 #[test]
464 fn test_system_message_status_null() {
465 let json = r#"{
466 "type": "system",
467 "subtype": "status",
468 "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
469 "status": null,
470 "uuid": "92d9637e-d00e-418e-acd2-a504e3861c6a"
471 }"#;
472
473 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
474 if let ClaudeOutput::System(sys) = output {
475 let status = sys.as_status().expect("Should parse as status");
476 assert_eq!(status.status, None);
477 } else {
478 panic!("Expected System message");
479 }
480 }
481
482 #[test]
483 fn test_system_message_task_started() {
484 let json = r#"{
485 "type": "system",
486 "subtype": "task_started",
487 "session_id": "9abbc466-dad0-4b8e-b6b0-cad5eb7a16b9",
488 "task_id": "b6daf3f",
489 "task_type": "local_bash",
490 "tool_use_id": "toolu_011rfSTFumpJZdCCfzeD7jaS",
491 "description": "Wait for CI on PR #12",
492 "uuid": "c4243261-c128-4747-b8c3-5e1c7c10eeb8"
493 }"#;
494
495 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
496 if let ClaudeOutput::System(sys) = output {
497 assert!(sys.is_task_started());
498 assert!(!sys.is_task_progress());
499 assert!(!sys.is_task_notification());
500
501 let task = sys.as_task_started().expect("Should parse as task_started");
502 assert_eq!(task.session_id, "9abbc466-dad0-4b8e-b6b0-cad5eb7a16b9");
503 assert_eq!(task.task_id, "b6daf3f");
504 assert_eq!(task.task_type, super::TaskType::LocalBash);
505 assert_eq!(task.tool_use_id, "toolu_011rfSTFumpJZdCCfzeD7jaS");
506 assert_eq!(task.description, "Wait for CI on PR #12");
507 } else {
508 panic!("Expected System message");
509 }
510 }
511
512 #[test]
513 fn test_system_message_task_started_agent() {
514 let json = r#"{
515 "type": "system",
516 "subtype": "task_started",
517 "session_id": "bff4f716-17c1-4255-ab7b-eea9d33824e3",
518 "task_id": "a4a7e0906e5fc64cc",
519 "task_type": "local_agent",
520 "tool_use_id": "toolu_01SFz9FwZ1cYgCSy8vRM7wep",
521 "description": "Explore Scene/ArrayScene duplication",
522 "uuid": "85a39f5a-e4d4-47f7-9a6d-1125f1a8035f"
523 }"#;
524
525 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
526 if let ClaudeOutput::System(sys) = output {
527 let task = sys.as_task_started().expect("Should parse as task_started");
528 assert_eq!(task.task_type, super::TaskType::LocalAgent);
529 assert_eq!(task.task_id, "a4a7e0906e5fc64cc");
530 } else {
531 panic!("Expected System message");
532 }
533 }
534
535 #[test]
536 fn test_system_message_task_progress() {
537 let json = r#"{
538 "type": "system",
539 "subtype": "task_progress",
540 "session_id": "bff4f716-17c1-4255-ab7b-eea9d33824e3",
541 "task_id": "a4a7e0906e5fc64cc",
542 "tool_use_id": "toolu_01SFz9FwZ1cYgCSy8vRM7wep",
543 "description": "Reading src/jplephem/chebyshev.rs",
544 "last_tool_name": "Read",
545 "usage": {
546 "duration_ms": 13996,
547 "tool_uses": 9,
548 "total_tokens": 38779
549 },
550 "uuid": "85a39f5a-e4d4-47f7-9a6d-1125f1a8035f"
551 }"#;
552
553 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
554 if let ClaudeOutput::System(sys) = output {
555 assert!(sys.is_task_progress());
556 assert!(!sys.is_task_started());
557
558 let progress = sys
559 .as_task_progress()
560 .expect("Should parse as task_progress");
561 assert_eq!(progress.task_id, "a4a7e0906e5fc64cc");
562 assert_eq!(progress.description, "Reading src/jplephem/chebyshev.rs");
563 assert_eq!(progress.last_tool_name, "Read");
564 assert_eq!(progress.usage.duration_ms, 13996);
565 assert_eq!(progress.usage.tool_uses, 9);
566 assert_eq!(progress.usage.total_tokens, 38779);
567 } else {
568 panic!("Expected System message");
569 }
570 }
571
572 #[test]
573 fn test_system_message_task_notification_completed() {
574 let json = r#"{
575 "type": "system",
576 "subtype": "task_notification",
577 "session_id": "bff4f716-17c1-4255-ab7b-eea9d33824e3",
578 "task_id": "a0ba761e9dc9c316f",
579 "tool_use_id": "toolu_01Ho6XVXFLVNjTQ9YqowdBXW",
580 "status": "completed",
581 "summary": "Agent \"Write Hipparcos data source doc\" completed",
582 "output_file": "",
583 "usage": {
584 "duration_ms": 172300,
585 "tool_uses": 11,
586 "total_tokens": 42005
587 },
588 "uuid": "269f49b9-218d-4c8d-9f7e-3a5383a0c5b2"
589 }"#;
590
591 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
592 if let ClaudeOutput::System(sys) = output {
593 assert!(sys.is_task_notification());
594
595 let notif = sys
596 .as_task_notification()
597 .expect("Should parse as task_notification");
598 assert_eq!(notif.status, super::TaskStatus::Completed);
599 assert_eq!(
600 notif.summary,
601 "Agent \"Write Hipparcos data source doc\" completed"
602 );
603 assert_eq!(notif.output_file, Some("".to_string()));
604 assert_eq!(
605 notif.tool_use_id,
606 Some("toolu_01Ho6XVXFLVNjTQ9YqowdBXW".to_string())
607 );
608 let usage = notif.usage.expect("Should have usage");
609 assert_eq!(usage.duration_ms, 172300);
610 assert_eq!(usage.tool_uses, 11);
611 assert_eq!(usage.total_tokens, 42005);
612 } else {
613 panic!("Expected System message");
614 }
615 }
616
617 #[test]
618 fn test_system_message_task_notification_failed_no_usage() {
619 let json = r#"{
620 "type": "system",
621 "subtype": "task_notification",
622 "session_id": "ea629737-3c36-48a8-a1c4-ad761ad35784",
623 "task_id": "b98f6a3",
624 "status": "failed",
625 "summary": "Background command \"Run FSM calibration\" failed with exit code 1",
626 "output_file": "/tmp/claude-1000/tasks/b98f6a3.output"
627 }"#;
628
629 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
630 if let ClaudeOutput::System(sys) = output {
631 let notif = sys
632 .as_task_notification()
633 .expect("Should parse as task_notification");
634 assert_eq!(notif.status, super::TaskStatus::Failed);
635 assert!(notif.tool_use_id.is_none());
636 assert!(notif.usage.is_none());
637 assert_eq!(
638 notif.output_file,
639 Some("/tmp/claude-1000/tasks/b98f6a3.output".to_string())
640 );
641 } else {
642 panic!("Expected System message");
643 }
644 }
645
646 #[test]
647 fn test_system_message_compact_boundary() {
648 let json = r#"{
649 "type": "system",
650 "subtype": "compact_boundary",
651 "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
652 "compact_metadata": {
653 "pre_tokens": 155285,
654 "trigger": "auto"
655 },
656 "uuid": "a67780d5-74cb-48b1-9137-7a6e7cee45d7"
657 }"#;
658
659 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
660 if let ClaudeOutput::System(sys) = output {
661 assert!(sys.is_compact_boundary());
662 assert!(!sys.is_init());
663 assert!(!sys.is_status());
664
665 let compact = sys
666 .as_compact_boundary()
667 .expect("Should parse as compact_boundary");
668 assert_eq!(compact.session_id, "879c1a88-3756-4092-aa95-0020c4ed9692");
669 assert_eq!(compact.compact_metadata.pre_tokens, 155285);
670 assert_eq!(compact.compact_metadata.trigger, "auto");
671 } else {
672 panic!("Expected System message");
673 }
674 }
675}