Skip to main content

claude_codes/io/
message_types.rs

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/// Known system message subtypes.
9///
10/// The Claude CLI emits system messages with a `subtype` field indicating what
11/// kind of system event occurred. This enum captures the known subtypes while
12/// preserving unknown values via the `Unknown` variant for forward compatibility.
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub enum SystemSubtype {
15    Init,
16    Status,
17    CompactBoundary,
18    ThinkingTokens,
19    TaskStarted,
20    TaskProgress,
21    TaskUpdated,
22    TaskNotification,
23    /// A subtype not yet known to this version of the crate.
24    Unknown(String),
25}
26
27impl SystemSubtype {
28    pub fn as_str(&self) -> &str {
29        match self {
30            Self::Init => "init",
31            Self::Status => "status",
32            Self::CompactBoundary => "compact_boundary",
33            Self::ThinkingTokens => "thinking_tokens",
34            Self::TaskStarted => "task_started",
35            Self::TaskProgress => "task_progress",
36            Self::TaskUpdated => "task_updated",
37            Self::TaskNotification => "task_notification",
38            Self::Unknown(s) => s.as_str(),
39        }
40    }
41}
42
43impl fmt::Display for SystemSubtype {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.write_str(self.as_str())
46    }
47}
48
49impl From<&str> for SystemSubtype {
50    fn from(s: &str) -> Self {
51        match s {
52            "init" => Self::Init,
53            "status" => Self::Status,
54            "compact_boundary" => Self::CompactBoundary,
55            "thinking_tokens" => Self::ThinkingTokens,
56            "task_started" => Self::TaskStarted,
57            "task_progress" => Self::TaskProgress,
58            "task_updated" => Self::TaskUpdated,
59            "task_notification" => Self::TaskNotification,
60            other => Self::Unknown(other.to_string()),
61        }
62    }
63}
64
65impl Serialize for SystemSubtype {
66    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
67        serializer.serialize_str(self.as_str())
68    }
69}
70
71impl<'de> Deserialize<'de> for SystemSubtype {
72    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
73        let s = String::deserialize(deserializer)?;
74        Ok(Self::from(s.as_str()))
75    }
76}
77
78/// Known message roles.
79///
80/// Used in `MessageContent` and `AssistantMessageContent` to indicate the
81/// speaker of a message.
82#[derive(Debug, Clone, PartialEq, Eq, Hash)]
83pub enum MessageRole {
84    User,
85    Assistant,
86    /// A role not yet known to this version of the crate.
87    Unknown(String),
88}
89
90impl MessageRole {
91    pub fn as_str(&self) -> &str {
92        match self {
93            Self::User => "user",
94            Self::Assistant => "assistant",
95            Self::Unknown(s) => s.as_str(),
96        }
97    }
98}
99
100impl fmt::Display for MessageRole {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        f.write_str(self.as_str())
103    }
104}
105
106impl From<&str> for MessageRole {
107    fn from(s: &str) -> Self {
108        match s {
109            "user" => Self::User,
110            "assistant" => Self::Assistant,
111            other => Self::Unknown(other.to_string()),
112        }
113    }
114}
115
116impl Serialize for MessageRole {
117    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
118        serializer.serialize_str(self.as_str())
119    }
120}
121
122impl<'de> Deserialize<'de> for MessageRole {
123    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
124        let s = String::deserialize(deserializer)?;
125        Ok(Self::from(s.as_str()))
126    }
127}
128
129/// What triggered a context compaction.
130#[derive(Debug, Clone, PartialEq, Eq, Hash)]
131pub enum CompactionTrigger {
132    /// Automatic compaction triggered by token limit.
133    Auto,
134    /// User-initiated compaction (e.g., /compact command).
135    Manual,
136    /// A trigger not yet known to this version of the crate.
137    Unknown(String),
138}
139
140impl CompactionTrigger {
141    pub fn as_str(&self) -> &str {
142        match self {
143            Self::Auto => "auto",
144            Self::Manual => "manual",
145            Self::Unknown(s) => s.as_str(),
146        }
147    }
148}
149
150impl fmt::Display for CompactionTrigger {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        f.write_str(self.as_str())
153    }
154}
155
156impl From<&str> for CompactionTrigger {
157    fn from(s: &str) -> Self {
158        match s {
159            "auto" => Self::Auto,
160            "manual" => Self::Manual,
161            other => Self::Unknown(other.to_string()),
162        }
163    }
164}
165
166impl Serialize for CompactionTrigger {
167    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
168        serializer.serialize_str(self.as_str())
169    }
170}
171
172impl<'de> Deserialize<'de> for CompactionTrigger {
173    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
174        let s = String::deserialize(deserializer)?;
175        Ok(Self::from(s.as_str()))
176    }
177}
178
179/// Reason why the assistant stopped generating.
180#[derive(Debug, Clone, PartialEq, Eq, Hash)]
181pub enum StopReason {
182    /// The assistant reached a natural end of its turn.
183    EndTurn,
184    /// The response hit the maximum token limit.
185    MaxTokens,
186    /// The assistant wants to use a tool.
187    ToolUse,
188    /// A stop reason not yet known to this version of the crate.
189    Unknown(String),
190}
191
192impl StopReason {
193    pub fn as_str(&self) -> &str {
194        match self {
195            Self::EndTurn => "end_turn",
196            Self::MaxTokens => "max_tokens",
197            Self::ToolUse => "tool_use",
198            Self::Unknown(s) => s.as_str(),
199        }
200    }
201}
202
203impl fmt::Display for StopReason {
204    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205        f.write_str(self.as_str())
206    }
207}
208
209impl From<&str> for StopReason {
210    fn from(s: &str) -> Self {
211        match s {
212            "end_turn" => Self::EndTurn,
213            "max_tokens" => Self::MaxTokens,
214            "tool_use" => Self::ToolUse,
215            other => Self::Unknown(other.to_string()),
216        }
217    }
218}
219
220impl Serialize for StopReason {
221    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
222        serializer.serialize_str(self.as_str())
223    }
224}
225
226impl<'de> Deserialize<'de> for StopReason {
227    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
228        let s = String::deserialize(deserializer)?;
229        Ok(Self::from(s.as_str()))
230    }
231}
232
233/// How the API key was sourced for the session.
234#[derive(Debug, Clone, PartialEq, Eq, Hash)]
235pub enum ApiKeySource {
236    /// No API key provided.
237    None,
238    /// A source not yet known to this version of the crate.
239    Unknown(String),
240}
241
242impl ApiKeySource {
243    pub fn as_str(&self) -> &str {
244        match self {
245            Self::None => "none",
246            Self::Unknown(s) => s.as_str(),
247        }
248    }
249}
250
251impl fmt::Display for ApiKeySource {
252    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
253        f.write_str(self.as_str())
254    }
255}
256
257impl From<&str> for ApiKeySource {
258    fn from(s: &str) -> Self {
259        match s {
260            "none" => Self::None,
261            other => Self::Unknown(other.to_string()),
262        }
263    }
264}
265
266impl Serialize for ApiKeySource {
267    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
268        serializer.serialize_str(self.as_str())
269    }
270}
271
272impl<'de> Deserialize<'de> for ApiKeySource {
273    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
274        let s = String::deserialize(deserializer)?;
275        Ok(Self::from(s.as_str()))
276    }
277}
278
279/// Output formatting style for the session.
280#[derive(Debug, Clone, PartialEq, Eq, Hash)]
281pub enum OutputStyle {
282    /// Default output style.
283    Default,
284    /// A style not yet known to this version of the crate.
285    Unknown(String),
286}
287
288impl OutputStyle {
289    pub fn as_str(&self) -> &str {
290        match self {
291            Self::Default => "default",
292            Self::Unknown(s) => s.as_str(),
293        }
294    }
295}
296
297impl fmt::Display for OutputStyle {
298    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
299        f.write_str(self.as_str())
300    }
301}
302
303impl From<&str> for OutputStyle {
304    fn from(s: &str) -> Self {
305        match s {
306            "default" => Self::Default,
307            other => Self::Unknown(other.to_string()),
308        }
309    }
310}
311
312impl Serialize for OutputStyle {
313    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
314        serializer.serialize_str(self.as_str())
315    }
316}
317
318impl<'de> Deserialize<'de> for OutputStyle {
319    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
320        let s = String::deserialize(deserializer)?;
321        Ok(Self::from(s.as_str()))
322    }
323}
324
325/// Permission mode reported in init messages.
326#[derive(Debug, Clone, PartialEq, Eq, Hash)]
327pub enum InitPermissionMode {
328    /// Default permission mode.
329    Default,
330    /// A mode not yet known to this version of the crate.
331    Unknown(String),
332}
333
334impl InitPermissionMode {
335    pub fn as_str(&self) -> &str {
336        match self {
337            Self::Default => "default",
338            Self::Unknown(s) => s.as_str(),
339        }
340    }
341}
342
343impl fmt::Display for InitPermissionMode {
344    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
345        f.write_str(self.as_str())
346    }
347}
348
349impl From<&str> for InitPermissionMode {
350    fn from(s: &str) -> Self {
351        match s {
352            "default" => Self::Default,
353            other => Self::Unknown(other.to_string()),
354        }
355    }
356}
357
358impl Serialize for InitPermissionMode {
359    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
360        serializer.serialize_str(self.as_str())
361    }
362}
363
364impl<'de> Deserialize<'de> for InitPermissionMode {
365    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
366        let s = String::deserialize(deserializer)?;
367        Ok(Self::from(s.as_str()))
368    }
369}
370
371/// Status of an ongoing operation (e.g., context compaction).
372#[derive(Debug, Clone, PartialEq, Eq, Hash)]
373pub enum StatusMessageStatus {
374    /// Context compaction is in progress.
375    Compacting,
376    /// A status not yet known to this version of the crate.
377    Unknown(String),
378}
379
380impl StatusMessageStatus {
381    pub fn as_str(&self) -> &str {
382        match self {
383            Self::Compacting => "compacting",
384            Self::Unknown(s) => s.as_str(),
385        }
386    }
387}
388
389impl fmt::Display for StatusMessageStatus {
390    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
391        f.write_str(self.as_str())
392    }
393}
394
395impl From<&str> for StatusMessageStatus {
396    fn from(s: &str) -> Self {
397        match s {
398            "compacting" => Self::Compacting,
399            other => Self::Unknown(other.to_string()),
400        }
401    }
402}
403
404impl Serialize for StatusMessageStatus {
405    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
406        serializer.serialize_str(self.as_str())
407    }
408}
409
410impl<'de> Deserialize<'de> for StatusMessageStatus {
411    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
412        let s = String::deserialize(deserializer)?;
413        Ok(Self::from(s.as_str()))
414    }
415}
416
417/// Serialize an optional UUID as a string
418pub(crate) fn serialize_optional_uuid<S>(
419    uuid: &Option<Uuid>,
420    serializer: S,
421) -> Result<S::Ok, S::Error>
422where
423    S: Serializer,
424{
425    match uuid {
426        Some(id) => serializer.serialize_str(&id.to_string()),
427        None => serializer.serialize_none(),
428    }
429}
430
431/// Deserialize an optional UUID from a string
432pub(crate) fn deserialize_optional_uuid<'de, D>(deserializer: D) -> Result<Option<Uuid>, D::Error>
433where
434    D: Deserializer<'de>,
435{
436    let opt_str: Option<String> = Option::deserialize(deserializer)?;
437    match opt_str {
438        Some(s) => Uuid::parse_str(&s)
439            .map(Some)
440            .map_err(serde::de::Error::custom),
441        None => Ok(None),
442    }
443}
444
445/// User message
446#[derive(Debug, Clone, Serialize, Deserialize)]
447pub struct UserMessage {
448    pub message: MessageContent,
449    #[serde(skip_serializing_if = "Option::is_none")]
450    #[serde(
451        serialize_with = "serialize_optional_uuid",
452        deserialize_with = "deserialize_optional_uuid"
453    )]
454    pub session_id: Option<Uuid>,
455    /// Parent tool use ID for nested agent messages
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub parent_tool_use_id: Option<String>,
458    /// Message-level unique identifier
459    #[serde(skip_serializing_if = "Option::is_none")]
460    pub uuid: Option<String>,
461    /// CLI-emitted ISO-8601 timestamp for the message (present on echoed tool results).
462    #[serde(skip_serializing_if = "Option::is_none")]
463    pub timestamp: Option<String>,
464    /// Structured tool result data echoed by the CLI alongside the `tool_result`
465    /// content block. The shape depends on which tool produced it (e.g. for
466    /// `AskUserQuestion` it is `{ questions, answers }`; for `Bash` it is
467    /// `{ stdout, stderr, exit_code, ... }`). Stored as raw JSON to preserve
468    /// wire fidelity; use [`UserMessage::tool_use_result_as`] to parse into a
469    /// typed shape when you know which tool was invoked.
470    #[serde(skip_serializing_if = "Option::is_none")]
471    pub tool_use_result: Option<serde_json::Value>,
472    /// Subagent type, when this user message is the prompt echoed into a
473    /// `local_agent` subagent (e.g. `general-purpose`).
474    #[serde(skip_serializing_if = "Option::is_none")]
475    pub subagent_type: Option<String>,
476    /// Short description of the subagent task, present alongside `subagent_type`.
477    #[serde(skip_serializing_if = "Option::is_none")]
478    pub task_description: Option<String>,
479}
480
481impl UserMessage {
482    /// Parse the `tool_use_result` field into a caller-specified type.
483    ///
484    /// Returns `None` if `tool_use_result` is absent, otherwise returns the
485    /// deserialization result. The caller must know which tool produced the
486    /// result and supply a matching type — e.g. for `AskUserQuestion` use
487    /// [`AskUserQuestionInput`](crate::AskUserQuestionInput), whose
488    /// `questions` + `answers` fields match the wire result shape.
489    pub fn tool_use_result_as<T: serde::de::DeserializeOwned>(
490        &self,
491    ) -> Option<Result<T, serde_json::Error>> {
492        self.tool_use_result
493            .as_ref()
494            .map(|v| serde_json::from_value(v.clone()))
495    }
496}
497
498/// Message content with role
499#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct MessageContent {
501    pub role: MessageRole,
502    #[serde(deserialize_with = "deserialize_content_blocks")]
503    pub content: Vec<ContentBlock>,
504}
505
506/// System message with metadata
507#[derive(Debug, Clone, Serialize, Deserialize)]
508pub struct SystemMessage {
509    pub subtype: SystemSubtype,
510    #[serde(flatten)]
511    pub data: Value, // Captures all other fields
512}
513
514impl SystemMessage {
515    /// Check if this is an init message
516    pub fn is_init(&self) -> bool {
517        self.subtype == SystemSubtype::Init
518    }
519
520    /// Check if this is a status message
521    pub fn is_status(&self) -> bool {
522        self.subtype == SystemSubtype::Status
523    }
524
525    /// Check if this is a compact_boundary message
526    pub fn is_compact_boundary(&self) -> bool {
527        self.subtype == SystemSubtype::CompactBoundary
528    }
529
530    /// Try to parse as an init message
531    pub fn as_init(&self) -> Option<InitMessage> {
532        if self.subtype != SystemSubtype::Init {
533            return None;
534        }
535        serde_json::from_value(self.data.clone()).ok()
536    }
537
538    /// Try to parse as a status message
539    pub fn as_status(&self) -> Option<StatusMessage> {
540        if self.subtype != SystemSubtype::Status {
541            return None;
542        }
543        serde_json::from_value(self.data.clone()).ok()
544    }
545
546    /// Try to parse as a compact_boundary message
547    pub fn as_compact_boundary(&self) -> Option<CompactBoundaryMessage> {
548        if self.subtype != SystemSubtype::CompactBoundary {
549            return None;
550        }
551        serde_json::from_value(self.data.clone()).ok()
552    }
553
554    /// Check if this is a task_started message
555    pub fn is_task_started(&self) -> bool {
556        self.subtype == SystemSubtype::TaskStarted
557    }
558
559    /// Check if this is a task_progress message
560    pub fn is_task_progress(&self) -> bool {
561        self.subtype == SystemSubtype::TaskProgress
562    }
563
564    /// Check if this is a task_notification message
565    pub fn is_task_notification(&self) -> bool {
566        self.subtype == SystemSubtype::TaskNotification
567    }
568
569    /// Try to parse as a task_started message
570    pub fn as_task_started(&self) -> Option<TaskStartedMessage> {
571        if self.subtype != SystemSubtype::TaskStarted {
572            return None;
573        }
574        serde_json::from_value(self.data.clone()).ok()
575    }
576
577    /// Try to parse as a task_progress message
578    pub fn as_task_progress(&self) -> Option<TaskProgressMessage> {
579        if self.subtype != SystemSubtype::TaskProgress {
580            return None;
581        }
582        serde_json::from_value(self.data.clone()).ok()
583    }
584
585    /// Try to parse as a task_notification message
586    pub fn as_task_notification(&self) -> Option<TaskNotificationMessage> {
587        if self.subtype != SystemSubtype::TaskNotification {
588            return None;
589        }
590        serde_json::from_value(self.data.clone()).ok()
591    }
592
593    /// Check if this is a task_updated message
594    pub fn is_task_updated(&self) -> bool {
595        self.subtype == SystemSubtype::TaskUpdated
596    }
597
598    /// Try to parse as a task_updated message
599    pub fn as_task_updated(&self) -> Option<TaskUpdatedMessage> {
600        if self.subtype != SystemSubtype::TaskUpdated {
601            return None;
602        }
603        serde_json::from_value(self.data.clone()).ok()
604    }
605
606    /// Check if this is a thinking_tokens message
607    pub fn is_thinking_tokens(&self) -> bool {
608        self.subtype == SystemSubtype::ThinkingTokens
609    }
610
611    /// Try to parse as a thinking_tokens message
612    pub fn as_thinking_tokens(&self) -> Option<ThinkingTokensMessage> {
613        if self.subtype != SystemSubtype::ThinkingTokens {
614            return None;
615        }
616        serde_json::from_value(self.data.clone()).ok()
617    }
618
619    /// Re-serialize this system message's payload through the typed view that
620    /// matches its `subtype`, returning the result as JSON.
621    ///
622    /// Used by the wrapping audit ([`crate::io::audit_frame`]) to verify that a
623    /// subtype's dedicated struct captures every wire field: the audit compares
624    /// this against the raw [`SystemMessage::data`]. Returns `None` for subtypes
625    /// this crate version has no dedicated struct for (including
626    /// [`SystemSubtype::Unknown`]) — those are reported as not fully wrapped.
627    pub fn typed_value(&self) -> Option<Value> {
628        fn reserialize<T: Serialize>(parsed: Option<T>) -> Option<Value> {
629            parsed.and_then(|v| serde_json::to_value(v).ok())
630        }
631        match self.subtype {
632            SystemSubtype::Init => reserialize(self.as_init()),
633            SystemSubtype::Status => reserialize(self.as_status()),
634            SystemSubtype::CompactBoundary => reserialize(self.as_compact_boundary()),
635            SystemSubtype::ThinkingTokens => reserialize(self.as_thinking_tokens()),
636            SystemSubtype::TaskStarted => reserialize(self.as_task_started()),
637            SystemSubtype::TaskProgress => reserialize(self.as_task_progress()),
638            SystemSubtype::TaskUpdated => reserialize(self.as_task_updated()),
639            SystemSubtype::TaskNotification => reserialize(self.as_task_notification()),
640            SystemSubtype::Unknown(_) => None,
641        }
642    }
643}
644
645/// Plugin info from the init message
646#[derive(Debug, Clone, Serialize, Deserialize)]
647pub struct PluginInfo {
648    /// Plugin name
649    pub name: String,
650    /// Path to the plugin on disk
651    pub path: String,
652    /// Plugin registry source (e.g., "rust-analyzer-lsp@claude-plugins-official")
653    #[serde(skip_serializing_if = "Option::is_none")]
654    pub source: Option<String>,
655}
656
657/// Init system message data - sent at session start
658#[derive(Debug, Clone, Serialize, Deserialize)]
659pub struct InitMessage {
660    /// Session identifier
661    pub session_id: String,
662    /// Current working directory
663    #[serde(skip_serializing_if = "Option::is_none")]
664    pub cwd: Option<String>,
665    /// Model being used
666    #[serde(skip_serializing_if = "Option::is_none")]
667    pub model: Option<String>,
668    /// List of available tools
669    #[serde(default, skip_serializing_if = "Vec::is_empty")]
670    pub tools: Vec<String>,
671    /// MCP servers configured
672    #[serde(default, skip_serializing_if = "Vec::is_empty")]
673    pub mcp_servers: Vec<Value>,
674    /// Available slash commands (e.g., "compact", "cost", "review")
675    #[serde(default, skip_serializing_if = "Vec::is_empty")]
676    pub slash_commands: Vec<String>,
677    /// Available agent types (e.g., "Bash", "Explore", "Plan")
678    #[serde(default, skip_serializing_if = "Vec::is_empty")]
679    pub agents: Vec<String>,
680    /// Installed plugins
681    #[serde(default, skip_serializing_if = "Vec::is_empty")]
682    pub plugins: Vec<PluginInfo>,
683    /// Installed skills
684    #[serde(default, skip_serializing_if = "Vec::is_empty")]
685    pub skills: Vec<Value>,
686    /// Claude Code CLI version
687    #[serde(skip_serializing_if = "Option::is_none")]
688    pub claude_code_version: Option<String>,
689    /// How the API key was sourced
690    #[serde(skip_serializing_if = "Option::is_none", rename = "apiKeySource")]
691    pub api_key_source: Option<ApiKeySource>,
692    /// Output style
693    #[serde(skip_serializing_if = "Option::is_none")]
694    pub output_style: Option<OutputStyle>,
695    /// Permission mode
696    #[serde(skip_serializing_if = "Option::is_none", rename = "permissionMode")]
697    pub permission_mode: Option<InitPermissionMode>,
698
699    /// Message-level unique identifier
700    #[serde(skip_serializing_if = "Option::is_none")]
701    pub uuid: Option<String>,
702
703    /// Memory storage paths (e.g., {"auto": "/path/to/memory/"})
704    #[serde(skip_serializing_if = "Option::is_none")]
705    pub memory_paths: Option<Value>,
706
707    /// Fast mode toggle state (e.g., "off")
708    #[serde(skip_serializing_if = "Option::is_none")]
709    pub fast_mode_state: Option<String>,
710
711    /// Whether analytics collection is disabled for this session.
712    #[serde(default, skip_serializing_if = "Option::is_none")]
713    pub analytics_disabled: Option<bool>,
714
715    /// Whether product-feedback prompts are disabled for this session.
716    #[serde(default, skip_serializing_if = "Option::is_none")]
717    pub product_feedback_disabled: Option<bool>,
718}
719
720/// Status system message - sent during operations like context compaction
721#[derive(Debug, Clone, Serialize, Deserialize)]
722pub struct StatusMessage {
723    /// Session identifier
724    pub session_id: String,
725    /// Current status (e.g., compacting) or null when complete
726    pub status: Option<StatusMessageStatus>,
727    /// Unique identifier for this message
728    #[serde(skip_serializing_if = "Option::is_none")]
729    pub uuid: Option<String>,
730}
731
732/// Compact boundary message - marks where context compaction occurred
733#[derive(Debug, Clone, Serialize, Deserialize)]
734pub struct CompactBoundaryMessage {
735    /// Session identifier
736    pub session_id: String,
737    /// Metadata about the compaction
738    pub compact_metadata: CompactMetadata,
739    /// Human-readable summary of what was compacted, when the CLI emits one.
740    ///
741    /// Also accepted under the `content` / `text` wire keys.
742    #[serde(
743        default,
744        skip_serializing_if = "Option::is_none",
745        alias = "content",
746        alias = "text"
747    )]
748    pub summary: Option<String>,
749    /// Number of messages summarized in this compaction pass, when present.
750    ///
751    /// Also accepted under the `message_count` wire key.
752    #[serde(
753        default,
754        skip_serializing_if = "Option::is_none",
755        alias = "message_count"
756    )]
757    pub leaf_message_count: Option<u32>,
758    /// Wall-clock duration of the compaction pass in milliseconds, when present.
759    #[serde(default, skip_serializing_if = "Option::is_none")]
760    pub duration_ms: Option<u64>,
761    /// Unique identifier for this message
762    #[serde(skip_serializing_if = "Option::is_none")]
763    pub uuid: Option<String>,
764}
765
766/// Metadata about context compaction
767#[derive(Debug, Clone, Serialize, Deserialize)]
768pub struct CompactMetadata {
769    /// Number of tokens before compaction
770    pub pre_tokens: u64,
771    /// What triggered the compaction
772    pub trigger: CompactionTrigger,
773}
774
775// ---------------------------------------------------------------------------
776// Task system message types (task_started, task_progress, task_notification)
777// ---------------------------------------------------------------------------
778
779/// Cumulative usage statistics for a background task.
780#[derive(Debug, Clone, Serialize, Deserialize)]
781pub struct TaskUsage {
782    /// Wall-clock milliseconds since the task started.
783    pub duration_ms: u64,
784    /// Total number of tool calls made so far.
785    pub tool_uses: u64,
786    /// Total tokens consumed so far.
787    pub total_tokens: u64,
788}
789
790/// The kind of background task.
791#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
792#[serde(rename_all = "snake_case")]
793pub enum TaskType {
794    /// A sub-agent task (e.g., Explore, Plan).
795    LocalAgent,
796    /// A background bash command.
797    LocalBash,
798}
799
800/// Completion status of a background task.
801#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
802#[serde(rename_all = "snake_case")]
803pub enum TaskStatus {
804    Completed,
805    Failed,
806}
807
808/// `task_started` system message — emitted once when a background task begins.
809#[derive(Debug, Clone, Serialize, Deserialize)]
810pub struct TaskStartedMessage {
811    pub session_id: String,
812    pub task_id: String,
813    pub task_type: TaskType,
814    pub tool_use_id: String,
815    pub description: String,
816    /// The subagent type for `local_agent` tasks (e.g. `general-purpose`,
817    /// `Explore`). Absent for `local_bash` tasks.
818    #[serde(default, skip_serializing_if = "Option::is_none")]
819    pub subagent_type: Option<String>,
820    /// The prompt handed to the subagent. Present for `local_agent` tasks.
821    #[serde(default, skip_serializing_if = "Option::is_none")]
822    pub prompt: Option<String>,
823    pub uuid: String,
824}
825
826/// `task_updated` system message — emitted when a background task's state
827/// changes (e.g. transitions to `completed`). Carries a partial `patch` of the
828/// fields that changed rather than the full task record.
829#[derive(Debug, Clone, Serialize, Deserialize)]
830pub struct TaskUpdatedMessage {
831    pub session_id: String,
832    pub task_id: String,
833    pub patch: TaskPatch,
834    pub uuid: String,
835}
836
837/// The partial update carried by a [`TaskUpdatedMessage`]. Every field is
838/// optional because the CLI only sends the keys that changed.
839#[derive(Debug, Clone, Default, Serialize, Deserialize)]
840pub struct TaskPatch {
841    #[serde(default, skip_serializing_if = "Option::is_none")]
842    pub status: Option<TaskStatus>,
843    /// Wall-clock epoch milliseconds when the task finished, when the patch
844    /// reports completion.
845    #[serde(default, skip_serializing_if = "Option::is_none")]
846    pub end_time: Option<u64>,
847}
848
849/// `thinking_tokens` system message — emitted as the model streams extended
850/// thinking, reporting the running estimate of thinking tokens consumed.
851#[derive(Debug, Clone, Serialize, Deserialize)]
852pub struct ThinkingTokensMessage {
853    pub session_id: String,
854    /// Running estimate of total thinking tokens for the current turn.
855    pub estimated_tokens: u64,
856    /// Increase in the estimate since the previous `thinking_tokens` event.
857    pub estimated_tokens_delta: u64,
858    pub uuid: String,
859}
860
861/// `task_progress` system message — emitted periodically as a background
862/// agent task executes tools. Not emitted for `local_bash` tasks.
863#[derive(Debug, Clone, Serialize, Deserialize)]
864pub struct TaskProgressMessage {
865    pub session_id: String,
866    pub task_id: String,
867    pub tool_use_id: String,
868    pub description: String,
869    pub last_tool_name: String,
870    pub usage: TaskUsage,
871    /// Subagent type for `local_agent` tasks (e.g. `Explore`).
872    #[serde(default, skip_serializing_if = "Option::is_none")]
873    pub subagent_type: Option<String>,
874    pub uuid: String,
875}
876
877/// `task_notification` system message — emitted once when a background
878/// task completes or fails.
879#[derive(Debug, Clone, Serialize, Deserialize)]
880pub struct TaskNotificationMessage {
881    pub session_id: String,
882    pub task_id: String,
883    pub status: TaskStatus,
884    pub summary: String,
885    pub output_file: Option<String>,
886    #[serde(skip_serializing_if = "Option::is_none")]
887    pub tool_use_id: Option<String>,
888    #[serde(skip_serializing_if = "Option::is_none")]
889    pub usage: Option<TaskUsage>,
890    #[serde(skip_serializing_if = "Option::is_none")]
891    pub uuid: Option<String>,
892}
893
894/// Assistant message
895#[derive(Debug, Clone, Serialize, Deserialize)]
896pub struct AssistantMessage {
897    pub message: AssistantMessageContent,
898    pub session_id: String,
899    #[serde(skip_serializing_if = "Option::is_none")]
900    pub uuid: Option<String>,
901    #[serde(skip_serializing_if = "Option::is_none")]
902    pub parent_tool_use_id: Option<String>,
903    /// Anthropic API request id that produced this message (e.g. `req_...`).
904    #[serde(skip_serializing_if = "Option::is_none")]
905    pub request_id: Option<String>,
906    /// Subagent type, when this assistant message was produced inside a
907    /// `local_agent` subagent (e.g. `general-purpose`, `Explore`).
908    #[serde(skip_serializing_if = "Option::is_none")]
909    pub subagent_type: Option<String>,
910    /// Short description of the subagent task, present alongside `subagent_type`.
911    #[serde(skip_serializing_if = "Option::is_none")]
912    pub task_description: Option<String>,
913}
914
915/// Nested message content for assistant messages
916#[derive(Debug, Clone, Serialize, Deserialize)]
917pub struct AssistantMessageContent {
918    pub id: String,
919    /// The Anthropic API message type — always `"message"`.
920    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
921    pub message_type: Option<String>,
922    pub role: MessageRole,
923    pub model: String,
924    pub content: Vec<ContentBlock>,
925    #[serde(skip_serializing_if = "Option::is_none")]
926    pub stop_reason: Option<StopReason>,
927    #[serde(skip_serializing_if = "Option::is_none")]
928    pub stop_sequence: Option<String>,
929    #[serde(skip_serializing_if = "Option::is_none")]
930    pub usage: Option<AssistantUsage>,
931    /// Details about why generation stopped
932    #[serde(skip_serializing_if = "Option::is_none")]
933    pub stop_details: Option<Value>,
934    /// Context management metadata
935    #[serde(skip_serializing_if = "Option::is_none")]
936    pub context_management: Option<Value>,
937}
938
939/// Usage information for assistant messages
940#[derive(Debug, Clone, Serialize, Deserialize)]
941pub struct AssistantUsage {
942    /// Number of input tokens
943    #[serde(default)]
944    pub input_tokens: u32,
945
946    /// Number of output tokens
947    #[serde(default)]
948    pub output_tokens: u32,
949
950    /// Tokens used to create cache
951    #[serde(default)]
952    pub cache_creation_input_tokens: u32,
953
954    /// Tokens read from cache
955    #[serde(default)]
956    pub cache_read_input_tokens: u32,
957
958    /// Service tier used (e.g., "standard")
959    #[serde(skip_serializing_if = "Option::is_none")]
960    pub service_tier: Option<String>,
961
962    /// Detailed cache creation breakdown
963    #[serde(skip_serializing_if = "Option::is_none")]
964    pub cache_creation: Option<CacheCreationDetails>,
965
966    /// Inference geography (e.g., "not_available")
967    #[serde(skip_serializing_if = "Option::is_none")]
968    pub inference_geo: Option<String>,
969}
970
971/// Detailed cache creation information
972#[derive(Debug, Clone, Serialize, Deserialize)]
973pub struct CacheCreationDetails {
974    /// Ephemeral 1-hour input tokens
975    #[serde(default)]
976    pub ephemeral_1h_input_tokens: u32,
977
978    /// Ephemeral 5-minute input tokens
979    #[serde(default)]
980    pub ephemeral_5m_input_tokens: u32,
981}
982
983#[cfg(test)]
984mod tests {
985    use crate::io::ClaudeOutput;
986
987    #[test]
988    fn test_system_message_init() {
989        let json = r#"{
990            "type": "system",
991            "subtype": "init",
992            "session_id": "test-session-123",
993            "cwd": "/home/user/project",
994            "model": "claude-sonnet-4",
995            "tools": ["Bash", "Read", "Write"],
996            "mcp_servers": [],
997            "slash_commands": ["compact", "cost", "review"],
998            "agents": ["Bash", "Explore", "Plan"],
999            "plugins": [{"name": "rust-analyzer-lsp", "path": "/home/user/.claude/plugins/rust-analyzer-lsp/1.0.0"}],
1000            "skills": [],
1001            "claude_code_version": "2.1.15",
1002            "apiKeySource": "none",
1003            "output_style": "default",
1004            "permissionMode": "default"
1005        }"#;
1006
1007        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1008        if let ClaudeOutput::System(sys) = output {
1009            assert!(sys.is_init());
1010            assert!(!sys.is_status());
1011            assert!(!sys.is_compact_boundary());
1012
1013            let init = sys.as_init().expect("Should parse as init");
1014            assert_eq!(init.session_id, "test-session-123");
1015            assert_eq!(init.cwd, Some("/home/user/project".to_string()));
1016            assert_eq!(init.model, Some("claude-sonnet-4".to_string()));
1017            assert_eq!(init.tools, vec!["Bash", "Read", "Write"]);
1018            assert_eq!(init.slash_commands, vec!["compact", "cost", "review"]);
1019            assert_eq!(init.agents, vec!["Bash", "Explore", "Plan"]);
1020            assert_eq!(init.plugins.len(), 1);
1021            assert_eq!(init.plugins[0].name, "rust-analyzer-lsp");
1022            assert_eq!(init.claude_code_version, Some("2.1.15".to_string()));
1023            assert_eq!(init.api_key_source, Some(super::ApiKeySource::None));
1024            assert_eq!(init.output_style, Some(super::OutputStyle::Default));
1025            assert_eq!(
1026                init.permission_mode,
1027                Some(super::InitPermissionMode::Default)
1028            );
1029        } else {
1030            panic!("Expected System message");
1031        }
1032    }
1033
1034    #[test]
1035    fn test_system_message_init_from_real_capture() {
1036        let json = include_str!("../../test_cases/tool_use_captures/tool_msg_0.json");
1037        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1038        if let ClaudeOutput::System(sys) = output {
1039            let init = sys.as_init().expect("Should parse real init capture");
1040            assert_eq!(init.slash_commands.len(), 8);
1041            assert!(init.slash_commands.contains(&"compact".to_string()));
1042            assert!(init.slash_commands.contains(&"review".to_string()));
1043            assert_eq!(init.agents.len(), 5);
1044            assert!(init.agents.contains(&"Bash".to_string()));
1045            assert!(init.agents.contains(&"Explore".to_string()));
1046            assert_eq!(init.plugins.len(), 1);
1047            assert_eq!(init.plugins[0].name, "rust-analyzer-lsp");
1048            assert_eq!(init.claude_code_version, Some("2.1.15".to_string()));
1049        } else {
1050            panic!("Expected System message");
1051        }
1052    }
1053
1054    #[test]
1055    fn test_system_message_status() {
1056        let json = r#"{
1057            "type": "system",
1058            "subtype": "status",
1059            "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
1060            "status": "compacting",
1061            "uuid": "32eb9f9d-5ef7-47ff-8fce-bbe22fe7ed93"
1062        }"#;
1063
1064        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1065        if let ClaudeOutput::System(sys) = output {
1066            assert!(sys.is_status());
1067            assert!(!sys.is_init());
1068
1069            let status = sys.as_status().expect("Should parse as status");
1070            assert_eq!(status.session_id, "879c1a88-3756-4092-aa95-0020c4ed9692");
1071            assert_eq!(status.status, Some(super::StatusMessageStatus::Compacting));
1072            assert_eq!(
1073                status.uuid,
1074                Some("32eb9f9d-5ef7-47ff-8fce-bbe22fe7ed93".to_string())
1075            );
1076        } else {
1077            panic!("Expected System message");
1078        }
1079    }
1080
1081    #[test]
1082    fn test_system_message_status_null() {
1083        let json = r#"{
1084            "type": "system",
1085            "subtype": "status",
1086            "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
1087            "status": null,
1088            "uuid": "92d9637e-d00e-418e-acd2-a504e3861c6a"
1089        }"#;
1090
1091        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1092        if let ClaudeOutput::System(sys) = output {
1093            let status = sys.as_status().expect("Should parse as status");
1094            assert_eq!(status.status, None);
1095        } else {
1096            panic!("Expected System message");
1097        }
1098    }
1099
1100    #[test]
1101    fn test_system_message_task_started() {
1102        let json = r#"{
1103            "type": "system",
1104            "subtype": "task_started",
1105            "session_id": "9abbc466-dad0-4b8e-b6b0-cad5eb7a16b9",
1106            "task_id": "b6daf3f",
1107            "task_type": "local_bash",
1108            "tool_use_id": "toolu_011rfSTFumpJZdCCfzeD7jaS",
1109            "description": "Wait for CI on PR #12",
1110            "uuid": "c4243261-c128-4747-b8c3-5e1c7c10eeb8"
1111        }"#;
1112
1113        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1114        if let ClaudeOutput::System(sys) = output {
1115            assert!(sys.is_task_started());
1116            assert!(!sys.is_task_progress());
1117            assert!(!sys.is_task_notification());
1118
1119            let task = sys.as_task_started().expect("Should parse as task_started");
1120            assert_eq!(task.session_id, "9abbc466-dad0-4b8e-b6b0-cad5eb7a16b9");
1121            assert_eq!(task.task_id, "b6daf3f");
1122            assert_eq!(task.task_type, super::TaskType::LocalBash);
1123            assert_eq!(task.tool_use_id, "toolu_011rfSTFumpJZdCCfzeD7jaS");
1124            assert_eq!(task.description, "Wait for CI on PR #12");
1125        } else {
1126            panic!("Expected System message");
1127        }
1128    }
1129
1130    #[test]
1131    fn test_system_message_task_started_agent() {
1132        let json = r#"{
1133            "type": "system",
1134            "subtype": "task_started",
1135            "session_id": "bff4f716-17c1-4255-ab7b-eea9d33824e3",
1136            "task_id": "a4a7e0906e5fc64cc",
1137            "task_type": "local_agent",
1138            "tool_use_id": "toolu_01SFz9FwZ1cYgCSy8vRM7wep",
1139            "description": "Explore Scene/ArrayScene duplication",
1140            "uuid": "85a39f5a-e4d4-47f7-9a6d-1125f1a8035f"
1141        }"#;
1142
1143        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1144        if let ClaudeOutput::System(sys) = output {
1145            let task = sys.as_task_started().expect("Should parse as task_started");
1146            assert_eq!(task.task_type, super::TaskType::LocalAgent);
1147            assert_eq!(task.task_id, "a4a7e0906e5fc64cc");
1148        } else {
1149            panic!("Expected System message");
1150        }
1151    }
1152
1153    #[test]
1154    fn test_system_message_task_progress() {
1155        let json = r#"{
1156            "type": "system",
1157            "subtype": "task_progress",
1158            "session_id": "bff4f716-17c1-4255-ab7b-eea9d33824e3",
1159            "task_id": "a4a7e0906e5fc64cc",
1160            "tool_use_id": "toolu_01SFz9FwZ1cYgCSy8vRM7wep",
1161            "description": "Reading src/jplephem/chebyshev.rs",
1162            "last_tool_name": "Read",
1163            "usage": {
1164                "duration_ms": 13996,
1165                "tool_uses": 9,
1166                "total_tokens": 38779
1167            },
1168            "uuid": "85a39f5a-e4d4-47f7-9a6d-1125f1a8035f"
1169        }"#;
1170
1171        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1172        if let ClaudeOutput::System(sys) = output {
1173            assert!(sys.is_task_progress());
1174            assert!(!sys.is_task_started());
1175
1176            let progress = sys
1177                .as_task_progress()
1178                .expect("Should parse as task_progress");
1179            assert_eq!(progress.task_id, "a4a7e0906e5fc64cc");
1180            assert_eq!(progress.description, "Reading src/jplephem/chebyshev.rs");
1181            assert_eq!(progress.last_tool_name, "Read");
1182            assert_eq!(progress.usage.duration_ms, 13996);
1183            assert_eq!(progress.usage.tool_uses, 9);
1184            assert_eq!(progress.usage.total_tokens, 38779);
1185        } else {
1186            panic!("Expected System message");
1187        }
1188    }
1189
1190    #[test]
1191    fn test_system_message_task_notification_completed() {
1192        let json = r#"{
1193            "type": "system",
1194            "subtype": "task_notification",
1195            "session_id": "bff4f716-17c1-4255-ab7b-eea9d33824e3",
1196            "task_id": "a0ba761e9dc9c316f",
1197            "tool_use_id": "toolu_01Ho6XVXFLVNjTQ9YqowdBXW",
1198            "status": "completed",
1199            "summary": "Agent \"Write Hipparcos data source doc\" completed",
1200            "output_file": "",
1201            "usage": {
1202                "duration_ms": 172300,
1203                "tool_uses": 11,
1204                "total_tokens": 42005
1205            },
1206            "uuid": "269f49b9-218d-4c8d-9f7e-3a5383a0c5b2"
1207        }"#;
1208
1209        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1210        if let ClaudeOutput::System(sys) = output {
1211            assert!(sys.is_task_notification());
1212
1213            let notif = sys
1214                .as_task_notification()
1215                .expect("Should parse as task_notification");
1216            assert_eq!(notif.status, super::TaskStatus::Completed);
1217            assert_eq!(
1218                notif.summary,
1219                "Agent \"Write Hipparcos data source doc\" completed"
1220            );
1221            assert_eq!(notif.output_file, Some("".to_string()));
1222            assert_eq!(
1223                notif.tool_use_id,
1224                Some("toolu_01Ho6XVXFLVNjTQ9YqowdBXW".to_string())
1225            );
1226            let usage = notif.usage.expect("Should have usage");
1227            assert_eq!(usage.duration_ms, 172300);
1228            assert_eq!(usage.tool_uses, 11);
1229            assert_eq!(usage.total_tokens, 42005);
1230        } else {
1231            panic!("Expected System message");
1232        }
1233    }
1234
1235    #[test]
1236    fn test_system_message_task_notification_failed_no_usage() {
1237        let json = r#"{
1238            "type": "system",
1239            "subtype": "task_notification",
1240            "session_id": "ea629737-3c36-48a8-a1c4-ad761ad35784",
1241            "task_id": "b98f6a3",
1242            "status": "failed",
1243            "summary": "Background command \"Run FSM calibration\" failed with exit code 1",
1244            "output_file": "/tmp/claude-1000/tasks/b98f6a3.output"
1245        }"#;
1246
1247        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1248        if let ClaudeOutput::System(sys) = output {
1249            let notif = sys
1250                .as_task_notification()
1251                .expect("Should parse as task_notification");
1252            assert_eq!(notif.status, super::TaskStatus::Failed);
1253            assert!(notif.tool_use_id.is_none());
1254            assert!(notif.usage.is_none());
1255            assert_eq!(
1256                notif.output_file,
1257                Some("/tmp/claude-1000/tasks/b98f6a3.output".to_string())
1258            );
1259        } else {
1260            panic!("Expected System message");
1261        }
1262    }
1263
1264    /// Task system messages survive a `to_value` → `from_value` round-trip
1265    /// with their typed accessors still resolving. Mirrors the proxy/relay
1266    /// path where output is reparsed from a `serde_json::Value` rather than
1267    /// straight from the CLI's stdout, so a silently dropped or renamed field
1268    /// surfaces here instead of as a `None` downstream.
1269    #[test]
1270    fn test_task_messages_roundtrip_through_value() {
1271        let cases = [
1272            r#"{"type":"system","subtype":"task_started","session_id":"s1",
1273                "task_id":"t1","task_type":"local_bash","tool_use_id":"tu1",
1274                "description":"Sleep 3s","uuid":"u1"}"#,
1275            r#"{"type":"system","subtype":"task_progress","session_id":"s1",
1276                "task_id":"t1","tool_use_id":"tu1","description":"Running ls",
1277                "last_tool_name":"Bash",
1278                "usage":{"duration_ms":100,"tool_uses":1,"total_tokens":500},
1279                "uuid":"u2"}"#,
1280            r#"{"type":"system","subtype":"task_notification","session_id":"s1",
1281                "task_id":"t1","tool_use_id":"tu1","status":"completed",
1282                "summary":"done","output_file":"",
1283                "usage":{"duration_ms":100,"tool_uses":1,"total_tokens":500},
1284                "uuid":"u3"}"#,
1285        ];
1286
1287        for json in cases {
1288            let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1289            let value = serde_json::to_value(&output).unwrap();
1290            let reparsed: ClaudeOutput = serde_json::from_value(value).unwrap();
1291
1292            let ClaudeOutput::System(sys) = reparsed else {
1293                panic!("Expected System variant after round-trip");
1294            };
1295
1296            match sys.subtype {
1297                super::SystemSubtype::TaskStarted => {
1298                    assert!(
1299                        sys.as_task_started().is_some(),
1300                        "as_task_started failed after round-trip"
1301                    );
1302                }
1303                super::SystemSubtype::TaskProgress => {
1304                    assert!(
1305                        sys.as_task_progress().is_some(),
1306                        "as_task_progress failed after round-trip"
1307                    );
1308                }
1309                super::SystemSubtype::TaskNotification => {
1310                    assert!(
1311                        sys.as_task_notification().is_some(),
1312                        "as_task_notification failed after round-trip"
1313                    );
1314                }
1315                other => panic!("unexpected subtype after round-trip: {other:?}"),
1316            }
1317        }
1318    }
1319
1320    #[test]
1321    fn test_system_message_compact_boundary() {
1322        let json = r#"{
1323            "type": "system",
1324            "subtype": "compact_boundary",
1325            "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
1326            "compact_metadata": {
1327                "pre_tokens": 155285,
1328                "trigger": "auto"
1329            },
1330            "uuid": "a67780d5-74cb-48b1-9137-7a6e7cee45d7"
1331        }"#;
1332
1333        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1334        if let ClaudeOutput::System(sys) = output {
1335            assert!(sys.is_compact_boundary());
1336            assert!(!sys.is_init());
1337            assert!(!sys.is_status());
1338
1339            let compact = sys
1340                .as_compact_boundary()
1341                .expect("Should parse as compact_boundary");
1342            assert_eq!(compact.session_id, "879c1a88-3756-4092-aa95-0020c4ed9692");
1343            assert_eq!(compact.compact_metadata.pre_tokens, 155285);
1344            assert_eq!(
1345                compact.compact_metadata.trigger,
1346                super::CompactionTrigger::Auto
1347            );
1348            // Per-compaction stats are optional and absent here.
1349            assert!(compact.summary.is_none());
1350            assert!(compact.leaf_message_count.is_none());
1351            assert!(compact.duration_ms.is_none());
1352        } else {
1353            panic!("Expected System message");
1354        }
1355    }
1356
1357    #[test]
1358    fn test_compact_boundary_with_summary_stats() {
1359        // Canonical keys.
1360        let json = r#"{
1361            "type": "system",
1362            "subtype": "compact_boundary",
1363            "session_id": "s1",
1364            "compact_metadata": { "pre_tokens": 1000, "trigger": "manual" },
1365            "summary": "Summarized the earlier exploration.",
1366            "leaf_message_count": 42,
1367            "duration_ms": 1234,
1368            "uuid": "u1"
1369        }"#;
1370        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1371        let ClaudeOutput::System(sys) = output else {
1372            panic!("Expected System message");
1373        };
1374        let compact = sys.as_compact_boundary().expect("compact_boundary");
1375        assert_eq!(
1376            compact.summary.as_deref(),
1377            Some("Summarized the earlier exploration.")
1378        );
1379        assert_eq!(compact.leaf_message_count, Some(42));
1380        assert_eq!(compact.duration_ms, Some(1234));
1381
1382        // Alternate wire keys (`content` for summary, `message_count` for count)
1383        // deserialize into the same fields.
1384        let json_alt = r#"{
1385            "type": "system",
1386            "subtype": "compact_boundary",
1387            "session_id": "s2",
1388            "compact_metadata": { "pre_tokens": 2000, "trigger": "auto" },
1389            "content": "alt-key summary",
1390            "message_count": 7
1391        }"#;
1392        let output: ClaudeOutput = serde_json::from_str(json_alt).unwrap();
1393        let ClaudeOutput::System(sys) = output else {
1394            panic!("Expected System message");
1395        };
1396        let compact = sys.as_compact_boundary().expect("compact_boundary");
1397        assert_eq!(compact.summary.as_deref(), Some("alt-key summary"));
1398        assert_eq!(compact.leaf_message_count, Some(7));
1399    }
1400
1401    #[test]
1402    fn test_init_message_with_new_fields() {
1403        let json = r#"{
1404            "type": "system",
1405            "subtype": "init",
1406            "session_id": "test-session",
1407            "cwd": "/home/user",
1408            "model": "claude-opus-4-7",
1409            "tools": ["Bash"],
1410            "mcp_servers": [],
1411            "permissionMode": "default",
1412            "apiKeySource": "none",
1413            "uuid": "44841a0d-182d-493a-86b5-79800d3d9665",
1414            "memory_paths": {"auto": "/home/user/.claude/projects/memory/"},
1415            "fast_mode_state": "off",
1416            "plugins": [{"name": "lsp", "path": "/plugins/lsp", "source": "lsp@official"}],
1417            "claude_code_version": "2.1.117"
1418        }"#;
1419
1420        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1421        if let ClaudeOutput::System(sys) = output {
1422            let init = sys.as_init().expect("Should parse as init");
1423            assert_eq!(
1424                init.uuid.as_deref(),
1425                Some("44841a0d-182d-493a-86b5-79800d3d9665")
1426            );
1427            assert!(init.memory_paths.is_some());
1428            assert_eq!(init.fast_mode_state.as_deref(), Some("off"));
1429            assert_eq!(init.plugins[0].source.as_deref(), Some("lsp@official"));
1430            assert_eq!(init.claude_code_version.as_deref(), Some("2.1.117"));
1431        } else {
1432            panic!("Expected System message");
1433        }
1434    }
1435
1436    #[test]
1437    fn test_assistant_message_with_new_fields() {
1438        let json = r#"{
1439            "type": "assistant",
1440            "message": {
1441                "id": "msg_1",
1442                "type": "message",
1443                "role": "assistant",
1444                "model": "claude-opus-4-7",
1445                "content": [{"type": "text", "text": "Hello"}],
1446                "stop_reason": "end_turn",
1447                "stop_details": null,
1448                "context_management": null,
1449                "usage": {
1450                    "input_tokens": 100,
1451                    "output_tokens": 10,
1452                    "cache_creation_input_tokens": 50,
1453                    "cache_read_input_tokens": 0,
1454                    "service_tier": "standard",
1455                    "inference_geo": "not_available"
1456                }
1457            },
1458            "session_id": "abc",
1459            "uuid": "msg-uuid-123"
1460        }"#;
1461
1462        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1463        if let ClaudeOutput::Assistant(asst) = output {
1464            assert_eq!(asst.message.stop_details, None);
1465            assert_eq!(asst.message.context_management, None);
1466            let usage = asst.message.usage.unwrap();
1467            assert_eq!(usage.inference_geo.as_deref(), Some("not_available"));
1468        } else {
1469            panic!("Expected Assistant message");
1470        }
1471    }
1472
1473    #[test]
1474    fn test_user_message_with_new_fields() {
1475        let json = r#"{
1476            "type": "user",
1477            "message": {
1478                "role": "user",
1479                "content": [{"type": "text", "text": "Hello"}]
1480            },
1481            "session_id": "9abbc466-dad0-4b8e-b6b0-cad5eb7a16b9",
1482            "parent_tool_use_id": "toolu_123",
1483            "uuid": "user-msg-456"
1484        }"#;
1485
1486        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1487        if let ClaudeOutput::User(user) = output {
1488            assert_eq!(user.parent_tool_use_id.as_deref(), Some("toolu_123"));
1489            assert_eq!(user.uuid.as_deref(), Some("user-msg-456"));
1490        } else {
1491            panic!("Expected User message");
1492        }
1493    }
1494
1495    /// Real wire payload captured from the CLI after answering an
1496    /// AskUserQuestion via the permission control protocol. The top-level
1497    /// `tool_use_result` and `timestamp` fields must round-trip without loss —
1498    /// proxies using this crate to relay messages to a viewer rely on those
1499    /// fields being preserved (the viewer reads `tool_use_result.answers`).
1500    #[test]
1501    fn test_user_message_preserves_tool_use_result_and_timestamp() {
1502        let json = r#"{
1503            "type":"user",
1504            "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"}]},
1505            "parent_tool_use_id":null,
1506            "session_id":"622ae0c3-3d50-4fa7-9ee0-69d691238c6d",
1507            "uuid":"8ef6e997-a849-4d15-bed3-2837c3d3f4cd",
1508            "timestamp":"2026-05-12T23:12:04.121Z",
1509            "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"}}
1510        }"#;
1511
1512        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1513        let user = match output {
1514            ClaudeOutput::User(u) => u,
1515            other => panic!("Expected User message, got {:?}", other.message_type()),
1516        };
1517
1518        assert_eq!(user.timestamp.as_deref(), Some("2026-05-12T23:12:04.121Z"));
1519        let raw = user
1520            .tool_use_result
1521            .as_ref()
1522            .expect("tool_use_result must be captured");
1523        assert_eq!(raw["answers"]["Color"], "Blue");
1524        assert_eq!(raw["questions"][0]["header"], "Color");
1525
1526        // Round-trip: re-serialize and confirm tool_use_result + timestamp
1527        // survive — the bug we're guarding against is that the proxy silently
1528        // drops these fields when relaying user messages.
1529        let reser: serde_json::Value = serde_json::to_value(&user).unwrap();
1530        assert_eq!(reser["timestamp"], "2026-05-12T23:12:04.121Z");
1531        assert_eq!(reser["tool_use_result"]["answers"]["Color"], "Blue");
1532        assert_eq!(
1533            reser["tool_use_result"]["questions"][0]["question"],
1534            "Which color do you prefer?"
1535        );
1536
1537        // Typed accessor: AskUserQuestionInput has the same shape as the
1538        // AskUserQuestion tool_use_result.
1539        let typed: crate::AskUserQuestionInput = user
1540            .tool_use_result_as::<crate::AskUserQuestionInput>()
1541            .expect("tool_use_result present")
1542            .expect("AskUserQuestionInput parses");
1543        assert_eq!(typed.questions.len(), 1);
1544        assert_eq!(typed.questions[0].header, "Color");
1545        let answers = typed.answers.expect("answers populated");
1546        assert_eq!(answers.get("Color").map(String::as_str), Some("Blue"));
1547    }
1548
1549    /// User messages without `tool_use_result` / `timestamp` must still
1550    /// deserialize fine and serialize back without spuriously emitting nulls.
1551    #[test]
1552    fn test_user_message_without_tool_use_result_omits_field() {
1553        let json = r#"{
1554            "type":"user",
1555            "message":{"role":"user","content":[{"type":"text","text":"hello"}]},
1556            "session_id":"622ae0c3-3d50-4fa7-9ee0-69d691238c6d"
1557        }"#;
1558
1559        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1560        let user = match output {
1561            ClaudeOutput::User(u) => u,
1562            _ => panic!("Expected User message"),
1563        };
1564        assert!(user.tool_use_result.is_none());
1565        assert!(user.timestamp.is_none());
1566
1567        let reser = serde_json::to_value(&user).unwrap();
1568        assert!(reser.get("tool_use_result").is_none());
1569        assert!(reser.get("timestamp").is_none());
1570    }
1571}