1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use serde_json::Value;
3use std::fmt;
4use uuid::Uuid;
5
6use super::content_blocks::{deserialize_content_blocks, ContentBlock};
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub enum SystemSubtype {
15 Init,
16 Status,
17 CompactBoundary,
18 TaskStarted,
19 TaskProgress,
20 TaskNotification,
21 Unknown(String),
23}
24
25impl SystemSubtype {
26 pub fn as_str(&self) -> &str {
27 match self {
28 Self::Init => "init",
29 Self::Status => "status",
30 Self::CompactBoundary => "compact_boundary",
31 Self::TaskStarted => "task_started",
32 Self::TaskProgress => "task_progress",
33 Self::TaskNotification => "task_notification",
34 Self::Unknown(s) => s.as_str(),
35 }
36 }
37}
38
39impl fmt::Display for SystemSubtype {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 f.write_str(self.as_str())
42 }
43}
44
45impl From<&str> for SystemSubtype {
46 fn from(s: &str) -> Self {
47 match s {
48 "init" => Self::Init,
49 "status" => Self::Status,
50 "compact_boundary" => Self::CompactBoundary,
51 "task_started" => Self::TaskStarted,
52 "task_progress" => Self::TaskProgress,
53 "task_notification" => Self::TaskNotification,
54 other => Self::Unknown(other.to_string()),
55 }
56 }
57}
58
59impl Serialize for SystemSubtype {
60 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
61 serializer.serialize_str(self.as_str())
62 }
63}
64
65impl<'de> Deserialize<'de> for SystemSubtype {
66 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
67 let s = String::deserialize(deserializer)?;
68 Ok(Self::from(s.as_str()))
69 }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Hash)]
77pub enum MessageRole {
78 User,
79 Assistant,
80 Unknown(String),
82}
83
84impl MessageRole {
85 pub fn as_str(&self) -> &str {
86 match self {
87 Self::User => "user",
88 Self::Assistant => "assistant",
89 Self::Unknown(s) => s.as_str(),
90 }
91 }
92}
93
94impl fmt::Display for MessageRole {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 f.write_str(self.as_str())
97 }
98}
99
100impl From<&str> for MessageRole {
101 fn from(s: &str) -> Self {
102 match s {
103 "user" => Self::User,
104 "assistant" => Self::Assistant,
105 other => Self::Unknown(other.to_string()),
106 }
107 }
108}
109
110impl Serialize for MessageRole {
111 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
112 serializer.serialize_str(self.as_str())
113 }
114}
115
116impl<'de> Deserialize<'de> for MessageRole {
117 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
118 let s = String::deserialize(deserializer)?;
119 Ok(Self::from(s.as_str()))
120 }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Hash)]
125pub enum CompactionTrigger {
126 Auto,
128 Manual,
130 Unknown(String),
132}
133
134impl CompactionTrigger {
135 pub fn as_str(&self) -> &str {
136 match self {
137 Self::Auto => "auto",
138 Self::Manual => "manual",
139 Self::Unknown(s) => s.as_str(),
140 }
141 }
142}
143
144impl fmt::Display for CompactionTrigger {
145 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146 f.write_str(self.as_str())
147 }
148}
149
150impl From<&str> for CompactionTrigger {
151 fn from(s: &str) -> Self {
152 match s {
153 "auto" => Self::Auto,
154 "manual" => Self::Manual,
155 other => Self::Unknown(other.to_string()),
156 }
157 }
158}
159
160impl Serialize for CompactionTrigger {
161 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
162 serializer.serialize_str(self.as_str())
163 }
164}
165
166impl<'de> Deserialize<'de> for CompactionTrigger {
167 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
168 let s = String::deserialize(deserializer)?;
169 Ok(Self::from(s.as_str()))
170 }
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Hash)]
175pub enum StopReason {
176 EndTurn,
178 MaxTokens,
180 ToolUse,
182 Unknown(String),
184}
185
186impl StopReason {
187 pub fn as_str(&self) -> &str {
188 match self {
189 Self::EndTurn => "end_turn",
190 Self::MaxTokens => "max_tokens",
191 Self::ToolUse => "tool_use",
192 Self::Unknown(s) => s.as_str(),
193 }
194 }
195}
196
197impl fmt::Display for StopReason {
198 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199 f.write_str(self.as_str())
200 }
201}
202
203impl From<&str> for StopReason {
204 fn from(s: &str) -> Self {
205 match s {
206 "end_turn" => Self::EndTurn,
207 "max_tokens" => Self::MaxTokens,
208 "tool_use" => Self::ToolUse,
209 other => Self::Unknown(other.to_string()),
210 }
211 }
212}
213
214impl Serialize for StopReason {
215 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
216 serializer.serialize_str(self.as_str())
217 }
218}
219
220impl<'de> Deserialize<'de> for StopReason {
221 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
222 let s = String::deserialize(deserializer)?;
223 Ok(Self::from(s.as_str()))
224 }
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Hash)]
229pub enum ApiKeySource {
230 None,
232 Unknown(String),
234}
235
236impl ApiKeySource {
237 pub fn as_str(&self) -> &str {
238 match self {
239 Self::None => "none",
240 Self::Unknown(s) => s.as_str(),
241 }
242 }
243}
244
245impl fmt::Display for ApiKeySource {
246 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247 f.write_str(self.as_str())
248 }
249}
250
251impl From<&str> for ApiKeySource {
252 fn from(s: &str) -> Self {
253 match s {
254 "none" => Self::None,
255 other => Self::Unknown(other.to_string()),
256 }
257 }
258}
259
260impl Serialize for ApiKeySource {
261 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
262 serializer.serialize_str(self.as_str())
263 }
264}
265
266impl<'de> Deserialize<'de> for ApiKeySource {
267 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
268 let s = String::deserialize(deserializer)?;
269 Ok(Self::from(s.as_str()))
270 }
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Hash)]
275pub enum OutputStyle {
276 Default,
278 Unknown(String),
280}
281
282impl OutputStyle {
283 pub fn as_str(&self) -> &str {
284 match self {
285 Self::Default => "default",
286 Self::Unknown(s) => s.as_str(),
287 }
288 }
289}
290
291impl fmt::Display for OutputStyle {
292 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293 f.write_str(self.as_str())
294 }
295}
296
297impl From<&str> for OutputStyle {
298 fn from(s: &str) -> Self {
299 match s {
300 "default" => Self::Default,
301 other => Self::Unknown(other.to_string()),
302 }
303 }
304}
305
306impl Serialize for OutputStyle {
307 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
308 serializer.serialize_str(self.as_str())
309 }
310}
311
312impl<'de> Deserialize<'de> for OutputStyle {
313 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
314 let s = String::deserialize(deserializer)?;
315 Ok(Self::from(s.as_str()))
316 }
317}
318
319#[derive(Debug, Clone, PartialEq, Eq, Hash)]
321pub enum InitPermissionMode {
322 Default,
324 Unknown(String),
326}
327
328impl InitPermissionMode {
329 pub fn as_str(&self) -> &str {
330 match self {
331 Self::Default => "default",
332 Self::Unknown(s) => s.as_str(),
333 }
334 }
335}
336
337impl fmt::Display for InitPermissionMode {
338 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
339 f.write_str(self.as_str())
340 }
341}
342
343impl From<&str> for InitPermissionMode {
344 fn from(s: &str) -> Self {
345 match s {
346 "default" => Self::Default,
347 other => Self::Unknown(other.to_string()),
348 }
349 }
350}
351
352impl Serialize for InitPermissionMode {
353 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
354 serializer.serialize_str(self.as_str())
355 }
356}
357
358impl<'de> Deserialize<'de> for InitPermissionMode {
359 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
360 let s = String::deserialize(deserializer)?;
361 Ok(Self::from(s.as_str()))
362 }
363}
364
365#[derive(Debug, Clone, PartialEq, Eq, Hash)]
367pub enum StatusMessageStatus {
368 Compacting,
370 Unknown(String),
372}
373
374impl StatusMessageStatus {
375 pub fn as_str(&self) -> &str {
376 match self {
377 Self::Compacting => "compacting",
378 Self::Unknown(s) => s.as_str(),
379 }
380 }
381}
382
383impl fmt::Display for StatusMessageStatus {
384 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
385 f.write_str(self.as_str())
386 }
387}
388
389impl From<&str> for StatusMessageStatus {
390 fn from(s: &str) -> Self {
391 match s {
392 "compacting" => Self::Compacting,
393 other => Self::Unknown(other.to_string()),
394 }
395 }
396}
397
398impl Serialize for StatusMessageStatus {
399 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
400 serializer.serialize_str(self.as_str())
401 }
402}
403
404impl<'de> Deserialize<'de> for StatusMessageStatus {
405 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
406 let s = String::deserialize(deserializer)?;
407 Ok(Self::from(s.as_str()))
408 }
409}
410
411pub(crate) fn serialize_optional_uuid<S>(
413 uuid: &Option<Uuid>,
414 serializer: S,
415) -> Result<S::Ok, S::Error>
416where
417 S: Serializer,
418{
419 match uuid {
420 Some(id) => serializer.serialize_str(&id.to_string()),
421 None => serializer.serialize_none(),
422 }
423}
424
425pub(crate) fn deserialize_optional_uuid<'de, D>(deserializer: D) -> Result<Option<Uuid>, D::Error>
427where
428 D: Deserializer<'de>,
429{
430 let opt_str: Option<String> = Option::deserialize(deserializer)?;
431 match opt_str {
432 Some(s) => Uuid::parse_str(&s)
433 .map(Some)
434 .map_err(serde::de::Error::custom),
435 None => Ok(None),
436 }
437}
438
439#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct UserMessage {
442 pub message: MessageContent,
443 #[serde(skip_serializing_if = "Option::is_none")]
444 #[serde(
445 serialize_with = "serialize_optional_uuid",
446 deserialize_with = "deserialize_optional_uuid"
447 )]
448 pub session_id: Option<Uuid>,
449 #[serde(skip_serializing_if = "Option::is_none")]
451 pub parent_tool_use_id: Option<String>,
452 #[serde(skip_serializing_if = "Option::is_none")]
454 pub uuid: Option<String>,
455 #[serde(skip_serializing_if = "Option::is_none")]
457 pub timestamp: Option<String>,
458 #[serde(skip_serializing_if = "Option::is_none")]
465 pub tool_use_result: Option<serde_json::Value>,
466}
467
468impl UserMessage {
469 pub fn tool_use_result_as<T: serde::de::DeserializeOwned>(
477 &self,
478 ) -> Option<Result<T, serde_json::Error>> {
479 self.tool_use_result
480 .as_ref()
481 .map(|v| serde_json::from_value(v.clone()))
482 }
483}
484
485#[derive(Debug, Clone, Serialize, Deserialize)]
487pub struct MessageContent {
488 pub role: MessageRole,
489 #[serde(deserialize_with = "deserialize_content_blocks")]
490 pub content: Vec<ContentBlock>,
491}
492
493#[derive(Debug, Clone, Serialize, Deserialize)]
495pub struct SystemMessage {
496 pub subtype: SystemSubtype,
497 #[serde(flatten)]
498 pub data: Value, }
500
501impl SystemMessage {
502 pub fn is_init(&self) -> bool {
504 self.subtype == SystemSubtype::Init
505 }
506
507 pub fn is_status(&self) -> bool {
509 self.subtype == SystemSubtype::Status
510 }
511
512 pub fn is_compact_boundary(&self) -> bool {
514 self.subtype == SystemSubtype::CompactBoundary
515 }
516
517 pub fn as_init(&self) -> Option<InitMessage> {
519 if self.subtype != SystemSubtype::Init {
520 return None;
521 }
522 serde_json::from_value(self.data.clone()).ok()
523 }
524
525 pub fn as_status(&self) -> Option<StatusMessage> {
527 if self.subtype != SystemSubtype::Status {
528 return None;
529 }
530 serde_json::from_value(self.data.clone()).ok()
531 }
532
533 pub fn as_compact_boundary(&self) -> Option<CompactBoundaryMessage> {
535 if self.subtype != SystemSubtype::CompactBoundary {
536 return None;
537 }
538 serde_json::from_value(self.data.clone()).ok()
539 }
540
541 pub fn is_task_started(&self) -> bool {
543 self.subtype == SystemSubtype::TaskStarted
544 }
545
546 pub fn is_task_progress(&self) -> bool {
548 self.subtype == SystemSubtype::TaskProgress
549 }
550
551 pub fn is_task_notification(&self) -> bool {
553 self.subtype == SystemSubtype::TaskNotification
554 }
555
556 pub fn as_task_started(&self) -> Option<TaskStartedMessage> {
558 if self.subtype != SystemSubtype::TaskStarted {
559 return None;
560 }
561 serde_json::from_value(self.data.clone()).ok()
562 }
563
564 pub fn as_task_progress(&self) -> Option<TaskProgressMessage> {
566 if self.subtype != SystemSubtype::TaskProgress {
567 return None;
568 }
569 serde_json::from_value(self.data.clone()).ok()
570 }
571
572 pub fn as_task_notification(&self) -> Option<TaskNotificationMessage> {
574 if self.subtype != SystemSubtype::TaskNotification {
575 return None;
576 }
577 serde_json::from_value(self.data.clone()).ok()
578 }
579}
580
581#[derive(Debug, Clone, Serialize, Deserialize)]
583pub struct PluginInfo {
584 pub name: String,
586 pub path: String,
588 #[serde(skip_serializing_if = "Option::is_none")]
590 pub source: Option<String>,
591}
592
593#[derive(Debug, Clone, Serialize, Deserialize)]
595pub struct InitMessage {
596 pub session_id: String,
598 #[serde(skip_serializing_if = "Option::is_none")]
600 pub cwd: Option<String>,
601 #[serde(skip_serializing_if = "Option::is_none")]
603 pub model: Option<String>,
604 #[serde(default, skip_serializing_if = "Vec::is_empty")]
606 pub tools: Vec<String>,
607 #[serde(default, skip_serializing_if = "Vec::is_empty")]
609 pub mcp_servers: Vec<Value>,
610 #[serde(default, skip_serializing_if = "Vec::is_empty")]
612 pub slash_commands: Vec<String>,
613 #[serde(default, skip_serializing_if = "Vec::is_empty")]
615 pub agents: Vec<String>,
616 #[serde(default, skip_serializing_if = "Vec::is_empty")]
618 pub plugins: Vec<PluginInfo>,
619 #[serde(default, skip_serializing_if = "Vec::is_empty")]
621 pub skills: Vec<Value>,
622 #[serde(skip_serializing_if = "Option::is_none")]
624 pub claude_code_version: Option<String>,
625 #[serde(skip_serializing_if = "Option::is_none", rename = "apiKeySource")]
627 pub api_key_source: Option<ApiKeySource>,
628 #[serde(skip_serializing_if = "Option::is_none")]
630 pub output_style: Option<OutputStyle>,
631 #[serde(skip_serializing_if = "Option::is_none", rename = "permissionMode")]
633 pub permission_mode: Option<InitPermissionMode>,
634
635 #[serde(skip_serializing_if = "Option::is_none")]
637 pub uuid: Option<String>,
638
639 #[serde(skip_serializing_if = "Option::is_none")]
641 pub memory_paths: Option<Value>,
642
643 #[serde(skip_serializing_if = "Option::is_none")]
645 pub fast_mode_state: Option<String>,
646}
647
648#[derive(Debug, Clone, Serialize, Deserialize)]
650pub struct StatusMessage {
651 pub session_id: String,
653 pub status: Option<StatusMessageStatus>,
655 #[serde(skip_serializing_if = "Option::is_none")]
657 pub uuid: Option<String>,
658}
659
660#[derive(Debug, Clone, Serialize, Deserialize)]
662pub struct CompactBoundaryMessage {
663 pub session_id: String,
665 pub compact_metadata: CompactMetadata,
667 #[serde(skip_serializing_if = "Option::is_none")]
669 pub uuid: Option<String>,
670}
671
672#[derive(Debug, Clone, Serialize, Deserialize)]
674pub struct CompactMetadata {
675 pub pre_tokens: u64,
677 pub trigger: CompactionTrigger,
679}
680
681#[derive(Debug, Clone, Serialize, Deserialize)]
687pub struct TaskUsage {
688 pub duration_ms: u64,
690 pub tool_uses: u64,
692 pub total_tokens: u64,
694}
695
696#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
698#[serde(rename_all = "snake_case")]
699pub enum TaskType {
700 LocalAgent,
702 LocalBash,
704}
705
706#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
708#[serde(rename_all = "snake_case")]
709pub enum TaskStatus {
710 Completed,
711 Failed,
712}
713
714#[derive(Debug, Clone, Serialize, Deserialize)]
716pub struct TaskStartedMessage {
717 pub session_id: String,
718 pub task_id: String,
719 pub task_type: TaskType,
720 pub tool_use_id: String,
721 pub description: String,
722 pub uuid: String,
723}
724
725#[derive(Debug, Clone, Serialize, Deserialize)]
728pub struct TaskProgressMessage {
729 pub session_id: String,
730 pub task_id: String,
731 pub tool_use_id: String,
732 pub description: String,
733 pub last_tool_name: String,
734 pub usage: TaskUsage,
735 pub uuid: String,
736}
737
738#[derive(Debug, Clone, Serialize, Deserialize)]
741pub struct TaskNotificationMessage {
742 pub session_id: String,
743 pub task_id: String,
744 pub status: TaskStatus,
745 pub summary: String,
746 pub output_file: Option<String>,
747 #[serde(skip_serializing_if = "Option::is_none")]
748 pub tool_use_id: Option<String>,
749 #[serde(skip_serializing_if = "Option::is_none")]
750 pub usage: Option<TaskUsage>,
751 #[serde(skip_serializing_if = "Option::is_none")]
752 pub uuid: Option<String>,
753}
754
755#[derive(Debug, Clone, Serialize, Deserialize)]
757pub struct AssistantMessage {
758 pub message: AssistantMessageContent,
759 pub session_id: String,
760 #[serde(skip_serializing_if = "Option::is_none")]
761 pub uuid: Option<String>,
762 #[serde(skip_serializing_if = "Option::is_none")]
763 pub parent_tool_use_id: Option<String>,
764}
765
766#[derive(Debug, Clone, Serialize, Deserialize)]
768pub struct AssistantMessageContent {
769 pub id: String,
770 pub role: MessageRole,
771 pub model: String,
772 pub content: Vec<ContentBlock>,
773 #[serde(skip_serializing_if = "Option::is_none")]
774 pub stop_reason: Option<StopReason>,
775 #[serde(skip_serializing_if = "Option::is_none")]
776 pub stop_sequence: Option<String>,
777 #[serde(skip_serializing_if = "Option::is_none")]
778 pub usage: Option<AssistantUsage>,
779 #[serde(skip_serializing_if = "Option::is_none")]
781 pub stop_details: Option<Value>,
782 #[serde(skip_serializing_if = "Option::is_none")]
784 pub context_management: Option<Value>,
785}
786
787#[derive(Debug, Clone, Serialize, Deserialize)]
789pub struct AssistantUsage {
790 #[serde(default)]
792 pub input_tokens: u32,
793
794 #[serde(default)]
796 pub output_tokens: u32,
797
798 #[serde(default)]
800 pub cache_creation_input_tokens: u32,
801
802 #[serde(default)]
804 pub cache_read_input_tokens: u32,
805
806 #[serde(skip_serializing_if = "Option::is_none")]
808 pub service_tier: Option<String>,
809
810 #[serde(skip_serializing_if = "Option::is_none")]
812 pub cache_creation: Option<CacheCreationDetails>,
813
814 #[serde(skip_serializing_if = "Option::is_none")]
816 pub inference_geo: Option<String>,
817}
818
819#[derive(Debug, Clone, Serialize, Deserialize)]
821pub struct CacheCreationDetails {
822 #[serde(default)]
824 pub ephemeral_1h_input_tokens: u32,
825
826 #[serde(default)]
828 pub ephemeral_5m_input_tokens: u32,
829}
830
831#[cfg(test)]
832mod tests {
833 use crate::io::ClaudeOutput;
834
835 #[test]
836 fn test_system_message_init() {
837 let json = r#"{
838 "type": "system",
839 "subtype": "init",
840 "session_id": "test-session-123",
841 "cwd": "/home/user/project",
842 "model": "claude-sonnet-4",
843 "tools": ["Bash", "Read", "Write"],
844 "mcp_servers": [],
845 "slash_commands": ["compact", "cost", "review"],
846 "agents": ["Bash", "Explore", "Plan"],
847 "plugins": [{"name": "rust-analyzer-lsp", "path": "/home/user/.claude/plugins/rust-analyzer-lsp/1.0.0"}],
848 "skills": [],
849 "claude_code_version": "2.1.15",
850 "apiKeySource": "none",
851 "output_style": "default",
852 "permissionMode": "default"
853 }"#;
854
855 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
856 if let ClaudeOutput::System(sys) = output {
857 assert!(sys.is_init());
858 assert!(!sys.is_status());
859 assert!(!sys.is_compact_boundary());
860
861 let init = sys.as_init().expect("Should parse as init");
862 assert_eq!(init.session_id, "test-session-123");
863 assert_eq!(init.cwd, Some("/home/user/project".to_string()));
864 assert_eq!(init.model, Some("claude-sonnet-4".to_string()));
865 assert_eq!(init.tools, vec!["Bash", "Read", "Write"]);
866 assert_eq!(init.slash_commands, vec!["compact", "cost", "review"]);
867 assert_eq!(init.agents, vec!["Bash", "Explore", "Plan"]);
868 assert_eq!(init.plugins.len(), 1);
869 assert_eq!(init.plugins[0].name, "rust-analyzer-lsp");
870 assert_eq!(init.claude_code_version, Some("2.1.15".to_string()));
871 assert_eq!(init.api_key_source, Some(super::ApiKeySource::None));
872 assert_eq!(init.output_style, Some(super::OutputStyle::Default));
873 assert_eq!(
874 init.permission_mode,
875 Some(super::InitPermissionMode::Default)
876 );
877 } else {
878 panic!("Expected System message");
879 }
880 }
881
882 #[test]
883 fn test_system_message_init_from_real_capture() {
884 let json = include_str!("../../test_cases/tool_use_captures/tool_msg_0.json");
885 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
886 if let ClaudeOutput::System(sys) = output {
887 let init = sys.as_init().expect("Should parse real init capture");
888 assert_eq!(init.slash_commands.len(), 8);
889 assert!(init.slash_commands.contains(&"compact".to_string()));
890 assert!(init.slash_commands.contains(&"review".to_string()));
891 assert_eq!(init.agents.len(), 5);
892 assert!(init.agents.contains(&"Bash".to_string()));
893 assert!(init.agents.contains(&"Explore".to_string()));
894 assert_eq!(init.plugins.len(), 1);
895 assert_eq!(init.plugins[0].name, "rust-analyzer-lsp");
896 assert_eq!(init.claude_code_version, Some("2.1.15".to_string()));
897 } else {
898 panic!("Expected System message");
899 }
900 }
901
902 #[test]
903 fn test_system_message_status() {
904 let json = r#"{
905 "type": "system",
906 "subtype": "status",
907 "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
908 "status": "compacting",
909 "uuid": "32eb9f9d-5ef7-47ff-8fce-bbe22fe7ed93"
910 }"#;
911
912 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
913 if let ClaudeOutput::System(sys) = output {
914 assert!(sys.is_status());
915 assert!(!sys.is_init());
916
917 let status = sys.as_status().expect("Should parse as status");
918 assert_eq!(status.session_id, "879c1a88-3756-4092-aa95-0020c4ed9692");
919 assert_eq!(status.status, Some(super::StatusMessageStatus::Compacting));
920 assert_eq!(
921 status.uuid,
922 Some("32eb9f9d-5ef7-47ff-8fce-bbe22fe7ed93".to_string())
923 );
924 } else {
925 panic!("Expected System message");
926 }
927 }
928
929 #[test]
930 fn test_system_message_status_null() {
931 let json = r#"{
932 "type": "system",
933 "subtype": "status",
934 "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
935 "status": null,
936 "uuid": "92d9637e-d00e-418e-acd2-a504e3861c6a"
937 }"#;
938
939 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
940 if let ClaudeOutput::System(sys) = output {
941 let status = sys.as_status().expect("Should parse as status");
942 assert_eq!(status.status, None);
943 } else {
944 panic!("Expected System message");
945 }
946 }
947
948 #[test]
949 fn test_system_message_task_started() {
950 let json = r#"{
951 "type": "system",
952 "subtype": "task_started",
953 "session_id": "9abbc466-dad0-4b8e-b6b0-cad5eb7a16b9",
954 "task_id": "b6daf3f",
955 "task_type": "local_bash",
956 "tool_use_id": "toolu_011rfSTFumpJZdCCfzeD7jaS",
957 "description": "Wait for CI on PR #12",
958 "uuid": "c4243261-c128-4747-b8c3-5e1c7c10eeb8"
959 }"#;
960
961 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
962 if let ClaudeOutput::System(sys) = output {
963 assert!(sys.is_task_started());
964 assert!(!sys.is_task_progress());
965 assert!(!sys.is_task_notification());
966
967 let task = sys.as_task_started().expect("Should parse as task_started");
968 assert_eq!(task.session_id, "9abbc466-dad0-4b8e-b6b0-cad5eb7a16b9");
969 assert_eq!(task.task_id, "b6daf3f");
970 assert_eq!(task.task_type, super::TaskType::LocalBash);
971 assert_eq!(task.tool_use_id, "toolu_011rfSTFumpJZdCCfzeD7jaS");
972 assert_eq!(task.description, "Wait for CI on PR #12");
973 } else {
974 panic!("Expected System message");
975 }
976 }
977
978 #[test]
979 fn test_system_message_task_started_agent() {
980 let json = r#"{
981 "type": "system",
982 "subtype": "task_started",
983 "session_id": "bff4f716-17c1-4255-ab7b-eea9d33824e3",
984 "task_id": "a4a7e0906e5fc64cc",
985 "task_type": "local_agent",
986 "tool_use_id": "toolu_01SFz9FwZ1cYgCSy8vRM7wep",
987 "description": "Explore Scene/ArrayScene duplication",
988 "uuid": "85a39f5a-e4d4-47f7-9a6d-1125f1a8035f"
989 }"#;
990
991 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
992 if let ClaudeOutput::System(sys) = output {
993 let task = sys.as_task_started().expect("Should parse as task_started");
994 assert_eq!(task.task_type, super::TaskType::LocalAgent);
995 assert_eq!(task.task_id, "a4a7e0906e5fc64cc");
996 } else {
997 panic!("Expected System message");
998 }
999 }
1000
1001 #[test]
1002 fn test_system_message_task_progress() {
1003 let json = r#"{
1004 "type": "system",
1005 "subtype": "task_progress",
1006 "session_id": "bff4f716-17c1-4255-ab7b-eea9d33824e3",
1007 "task_id": "a4a7e0906e5fc64cc",
1008 "tool_use_id": "toolu_01SFz9FwZ1cYgCSy8vRM7wep",
1009 "description": "Reading src/jplephem/chebyshev.rs",
1010 "last_tool_name": "Read",
1011 "usage": {
1012 "duration_ms": 13996,
1013 "tool_uses": 9,
1014 "total_tokens": 38779
1015 },
1016 "uuid": "85a39f5a-e4d4-47f7-9a6d-1125f1a8035f"
1017 }"#;
1018
1019 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1020 if let ClaudeOutput::System(sys) = output {
1021 assert!(sys.is_task_progress());
1022 assert!(!sys.is_task_started());
1023
1024 let progress = sys
1025 .as_task_progress()
1026 .expect("Should parse as task_progress");
1027 assert_eq!(progress.task_id, "a4a7e0906e5fc64cc");
1028 assert_eq!(progress.description, "Reading src/jplephem/chebyshev.rs");
1029 assert_eq!(progress.last_tool_name, "Read");
1030 assert_eq!(progress.usage.duration_ms, 13996);
1031 assert_eq!(progress.usage.tool_uses, 9);
1032 assert_eq!(progress.usage.total_tokens, 38779);
1033 } else {
1034 panic!("Expected System message");
1035 }
1036 }
1037
1038 #[test]
1039 fn test_system_message_task_notification_completed() {
1040 let json = r#"{
1041 "type": "system",
1042 "subtype": "task_notification",
1043 "session_id": "bff4f716-17c1-4255-ab7b-eea9d33824e3",
1044 "task_id": "a0ba761e9dc9c316f",
1045 "tool_use_id": "toolu_01Ho6XVXFLVNjTQ9YqowdBXW",
1046 "status": "completed",
1047 "summary": "Agent \"Write Hipparcos data source doc\" completed",
1048 "output_file": "",
1049 "usage": {
1050 "duration_ms": 172300,
1051 "tool_uses": 11,
1052 "total_tokens": 42005
1053 },
1054 "uuid": "269f49b9-218d-4c8d-9f7e-3a5383a0c5b2"
1055 }"#;
1056
1057 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1058 if let ClaudeOutput::System(sys) = output {
1059 assert!(sys.is_task_notification());
1060
1061 let notif = sys
1062 .as_task_notification()
1063 .expect("Should parse as task_notification");
1064 assert_eq!(notif.status, super::TaskStatus::Completed);
1065 assert_eq!(
1066 notif.summary,
1067 "Agent \"Write Hipparcos data source doc\" completed"
1068 );
1069 assert_eq!(notif.output_file, Some("".to_string()));
1070 assert_eq!(
1071 notif.tool_use_id,
1072 Some("toolu_01Ho6XVXFLVNjTQ9YqowdBXW".to_string())
1073 );
1074 let usage = notif.usage.expect("Should have usage");
1075 assert_eq!(usage.duration_ms, 172300);
1076 assert_eq!(usage.tool_uses, 11);
1077 assert_eq!(usage.total_tokens, 42005);
1078 } else {
1079 panic!("Expected System message");
1080 }
1081 }
1082
1083 #[test]
1084 fn test_system_message_task_notification_failed_no_usage() {
1085 let json = r#"{
1086 "type": "system",
1087 "subtype": "task_notification",
1088 "session_id": "ea629737-3c36-48a8-a1c4-ad761ad35784",
1089 "task_id": "b98f6a3",
1090 "status": "failed",
1091 "summary": "Background command \"Run FSM calibration\" failed with exit code 1",
1092 "output_file": "/tmp/claude-1000/tasks/b98f6a3.output"
1093 }"#;
1094
1095 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1096 if let ClaudeOutput::System(sys) = output {
1097 let notif = sys
1098 .as_task_notification()
1099 .expect("Should parse as task_notification");
1100 assert_eq!(notif.status, super::TaskStatus::Failed);
1101 assert!(notif.tool_use_id.is_none());
1102 assert!(notif.usage.is_none());
1103 assert_eq!(
1104 notif.output_file,
1105 Some("/tmp/claude-1000/tasks/b98f6a3.output".to_string())
1106 );
1107 } else {
1108 panic!("Expected System message");
1109 }
1110 }
1111
1112 #[test]
1113 fn test_system_message_compact_boundary() {
1114 let json = r#"{
1115 "type": "system",
1116 "subtype": "compact_boundary",
1117 "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
1118 "compact_metadata": {
1119 "pre_tokens": 155285,
1120 "trigger": "auto"
1121 },
1122 "uuid": "a67780d5-74cb-48b1-9137-7a6e7cee45d7"
1123 }"#;
1124
1125 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1126 if let ClaudeOutput::System(sys) = output {
1127 assert!(sys.is_compact_boundary());
1128 assert!(!sys.is_init());
1129 assert!(!sys.is_status());
1130
1131 let compact = sys
1132 .as_compact_boundary()
1133 .expect("Should parse as compact_boundary");
1134 assert_eq!(compact.session_id, "879c1a88-3756-4092-aa95-0020c4ed9692");
1135 assert_eq!(compact.compact_metadata.pre_tokens, 155285);
1136 assert_eq!(
1137 compact.compact_metadata.trigger,
1138 super::CompactionTrigger::Auto
1139 );
1140 } else {
1141 panic!("Expected System message");
1142 }
1143 }
1144
1145 #[test]
1146 fn test_init_message_with_new_fields() {
1147 let json = r#"{
1148 "type": "system",
1149 "subtype": "init",
1150 "session_id": "test-session",
1151 "cwd": "/home/user",
1152 "model": "claude-opus-4-7",
1153 "tools": ["Bash"],
1154 "mcp_servers": [],
1155 "permissionMode": "default",
1156 "apiKeySource": "none",
1157 "uuid": "44841a0d-182d-493a-86b5-79800d3d9665",
1158 "memory_paths": {"auto": "/home/user/.claude/projects/memory/"},
1159 "fast_mode_state": "off",
1160 "plugins": [{"name": "lsp", "path": "/plugins/lsp", "source": "lsp@official"}],
1161 "claude_code_version": "2.1.117"
1162 }"#;
1163
1164 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1165 if let ClaudeOutput::System(sys) = output {
1166 let init = sys.as_init().expect("Should parse as init");
1167 assert_eq!(
1168 init.uuid.as_deref(),
1169 Some("44841a0d-182d-493a-86b5-79800d3d9665")
1170 );
1171 assert!(init.memory_paths.is_some());
1172 assert_eq!(init.fast_mode_state.as_deref(), Some("off"));
1173 assert_eq!(init.plugins[0].source.as_deref(), Some("lsp@official"));
1174 assert_eq!(init.claude_code_version.as_deref(), Some("2.1.117"));
1175 } else {
1176 panic!("Expected System message");
1177 }
1178 }
1179
1180 #[test]
1181 fn test_assistant_message_with_new_fields() {
1182 let json = r#"{
1183 "type": "assistant",
1184 "message": {
1185 "id": "msg_1",
1186 "type": "message",
1187 "role": "assistant",
1188 "model": "claude-opus-4-7",
1189 "content": [{"type": "text", "text": "Hello"}],
1190 "stop_reason": "end_turn",
1191 "stop_details": null,
1192 "context_management": null,
1193 "usage": {
1194 "input_tokens": 100,
1195 "output_tokens": 10,
1196 "cache_creation_input_tokens": 50,
1197 "cache_read_input_tokens": 0,
1198 "service_tier": "standard",
1199 "inference_geo": "not_available"
1200 }
1201 },
1202 "session_id": "abc",
1203 "uuid": "msg-uuid-123"
1204 }"#;
1205
1206 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1207 if let ClaudeOutput::Assistant(asst) = output {
1208 assert_eq!(asst.message.stop_details, None);
1209 assert_eq!(asst.message.context_management, None);
1210 let usage = asst.message.usage.unwrap();
1211 assert_eq!(usage.inference_geo.as_deref(), Some("not_available"));
1212 } else {
1213 panic!("Expected Assistant message");
1214 }
1215 }
1216
1217 #[test]
1218 fn test_user_message_with_new_fields() {
1219 let json = r#"{
1220 "type": "user",
1221 "message": {
1222 "role": "user",
1223 "content": [{"type": "text", "text": "Hello"}]
1224 },
1225 "session_id": "9abbc466-dad0-4b8e-b6b0-cad5eb7a16b9",
1226 "parent_tool_use_id": "toolu_123",
1227 "uuid": "user-msg-456"
1228 }"#;
1229
1230 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1231 if let ClaudeOutput::User(user) = output {
1232 assert_eq!(user.parent_tool_use_id.as_deref(), Some("toolu_123"));
1233 assert_eq!(user.uuid.as_deref(), Some("user-msg-456"));
1234 } else {
1235 panic!("Expected User message");
1236 }
1237 }
1238
1239 #[test]
1245 fn test_user_message_preserves_tool_use_result_and_timestamp() {
1246 let json = r#"{
1247 "type":"user",
1248 "message":{"role":"user","content":[{"type":"tool_result","content":"User has answered your questions: . You can now continue with the user's answers in mind.","tool_use_id":"toolu_01331duMqP2PrRaqR2yWa8e4"}]},
1249 "parent_tool_use_id":null,
1250 "session_id":"622ae0c3-3d50-4fa7-9ee0-69d691238c6d",
1251 "uuid":"8ef6e997-a849-4d15-bed3-2837c3d3f4cd",
1252 "timestamp":"2026-05-12T23:12:04.121Z",
1253 "tool_use_result":{"questions":[{"question":"Which color do you prefer?","header":"Color","options":[{"label":"Red","description":"A warm color"},{"label":"Blue","description":"A cool color"}],"multiSelect":false}],"answers":{"Color":"Blue"}}
1254 }"#;
1255
1256 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1257 let user = match output {
1258 ClaudeOutput::User(u) => u,
1259 other => panic!("Expected User message, got {:?}", other.message_type()),
1260 };
1261
1262 assert_eq!(user.timestamp.as_deref(), Some("2026-05-12T23:12:04.121Z"));
1263 let raw = user
1264 .tool_use_result
1265 .as_ref()
1266 .expect("tool_use_result must be captured");
1267 assert_eq!(raw["answers"]["Color"], "Blue");
1268 assert_eq!(raw["questions"][0]["header"], "Color");
1269
1270 let reser: serde_json::Value = serde_json::to_value(&user).unwrap();
1274 assert_eq!(reser["timestamp"], "2026-05-12T23:12:04.121Z");
1275 assert_eq!(reser["tool_use_result"]["answers"]["Color"], "Blue");
1276 assert_eq!(
1277 reser["tool_use_result"]["questions"][0]["question"],
1278 "Which color do you prefer?"
1279 );
1280
1281 let typed: crate::AskUserQuestionInput = user
1284 .tool_use_result_as::<crate::AskUserQuestionInput>()
1285 .expect("tool_use_result present")
1286 .expect("AskUserQuestionInput parses");
1287 assert_eq!(typed.questions.len(), 1);
1288 assert_eq!(typed.questions[0].header, "Color");
1289 let answers = typed.answers.expect("answers populated");
1290 assert_eq!(answers.get("Color").map(String::as_str), Some("Blue"));
1291 }
1292
1293 #[test]
1296 fn test_user_message_without_tool_use_result_omits_field() {
1297 let json = r#"{
1298 "type":"user",
1299 "message":{"role":"user","content":[{"type":"text","text":"hello"}]},
1300 "session_id":"622ae0c3-3d50-4fa7-9ee0-69d691238c6d"
1301 }"#;
1302
1303 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1304 let user = match output {
1305 ClaudeOutput::User(u) => u,
1306 _ => panic!("Expected User message"),
1307 };
1308 assert!(user.tool_use_result.is_none());
1309 assert!(user.timestamp.is_none());
1310
1311 let reser = serde_json::to_value(&user).unwrap();
1312 assert!(reser.get("tool_use_result").is_none());
1313 assert!(reser.get("timestamp").is_none());
1314 }
1315}