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    /// Parse the `tool_use_result` as a subagent (`Task`) run result.
498    ///
499    /// When this user message echoes the result of a `Task` tool call, the CLI
500    /// attaches a structured `tool_use_result` carrying the subagent's token,
501    /// timing, and tool-use accounting. Returns `None` when the field is absent
502    /// or does not parse as a [`SubagentResult`].
503    ///
504    /// Summing [`SubagentResult::total_tokens`] across every `Task` result in a
505    /// session yields the subagent token rollup the CLI renders as
506    /// `subagent_tokens` in its terminal `<usage>` block.
507    pub fn subagent_result(&self) -> Option<SubagentResult> {
508        self.tool_use_result
509            .as_ref()
510            .and_then(|v| serde_json::from_value(v.clone()).ok())
511    }
512}
513
514/// Token, timing, and tool-use accounting for a completed subagent (`Task`) run.
515///
516/// The Claude CLI echoes this object in the `tool_use_result` of a `Task` tool's
517/// result message. It is the typed source of truth for subagent token
518/// attribution: the per-run [`total_tokens`](Self::total_tokens),
519/// [`total_duration_ms`](Self::total_duration_ms), and
520/// [`total_tool_use_count`](Self::total_tool_use_count) correspond to the
521/// `subagent_tokens` / `duration_ms` / `tool_uses` line items the CLI renders in
522/// its human-readable `<usage>` block, and [`usage`](Self::usage) carries the
523/// full per-model token breakdown for the run.
524#[derive(Debug, Clone, Serialize, Deserialize)]
525pub struct SubagentResult {
526    /// Completion status of the subagent run (e.g. `"completed"`).
527    #[serde(skip_serializing_if = "Option::is_none")]
528    pub status: Option<String>,
529    /// The prompt the subagent was launched with.
530    #[serde(skip_serializing_if = "Option::is_none")]
531    pub prompt: Option<String>,
532    /// Stable identifier of the spawned subagent.
533    #[serde(rename = "agentId", skip_serializing_if = "Option::is_none")]
534    pub agent_id: Option<String>,
535    /// Subagent type that ran (e.g. `general-purpose`, `Explore`).
536    #[serde(rename = "agentType", skip_serializing_if = "Option::is_none")]
537    pub agent_type: Option<String>,
538    /// Final content blocks the subagent returned.
539    #[serde(
540        default,
541        deserialize_with = "deserialize_content_blocks",
542        skip_serializing_if = "Vec::is_empty"
543    )]
544    pub content: Vec<ContentBlock>,
545    /// Model the subagent actually resolved to (e.g. `claude-sonnet-4-6`).
546    #[serde(rename = "resolvedModel", skip_serializing_if = "Option::is_none")]
547    pub resolved_model: Option<String>,
548    /// Wall-clock duration of the subagent run, in milliseconds.
549    #[serde(rename = "totalDurationMs", skip_serializing_if = "Option::is_none")]
550    pub total_duration_ms: Option<u64>,
551    /// Total tokens consumed by the subagent — the `subagent_tokens` rollup line.
552    #[serde(rename = "totalTokens", skip_serializing_if = "Option::is_none")]
553    pub total_tokens: Option<u64>,
554    /// Number of tool invocations the subagent made.
555    #[serde(rename = "totalToolUseCount", skip_serializing_if = "Option::is_none")]
556    pub total_tool_use_count: Option<u64>,
557    /// Detailed token / cache usage for the subagent run.
558    #[serde(skip_serializing_if = "Option::is_none")]
559    pub usage: Option<super::result::UsageInfo>,
560    /// Per-category tool-use counts, present for some agent types (e.g. `Explore`).
561    #[serde(rename = "toolStats", skip_serializing_if = "Option::is_none")]
562    pub tool_stats: Option<SubagentToolStats>,
563}
564
565/// Per-category tool-use counts for a subagent run, from `tool_use_result.toolStats`.
566///
567/// The `extra` field captures any counters the CLI adds that aren't modeled here,
568/// so new wire fields deserialize without error.
569#[derive(Debug, Clone, Default, Serialize, Deserialize)]
570#[serde(rename_all = "camelCase")]
571pub struct SubagentToolStats {
572    #[serde(default)]
573    pub read_count: u64,
574    #[serde(default)]
575    pub search_count: u64,
576    #[serde(default)]
577    pub bash_count: u64,
578    #[serde(default)]
579    pub edit_file_count: u64,
580    #[serde(default)]
581    pub lines_added: u64,
582    #[serde(default)]
583    pub lines_removed: u64,
584    #[serde(default)]
585    pub other_tool_count: u64,
586    #[serde(flatten)]
587    pub extra: serde_json::Map<String, Value>,
588}
589
590/// Message content with role
591#[derive(Debug, Clone, Serialize, Deserialize)]
592pub struct MessageContent {
593    pub role: MessageRole,
594    #[serde(deserialize_with = "deserialize_content_blocks")]
595    pub content: Vec<ContentBlock>,
596}
597
598/// System message with metadata
599#[derive(Debug, Clone, Serialize, Deserialize)]
600pub struct SystemMessage {
601    pub subtype: SystemSubtype,
602    #[serde(flatten)]
603    pub data: Value, // Captures all other fields
604}
605
606impl SystemMessage {
607    /// Check if this is an init message
608    pub fn is_init(&self) -> bool {
609        self.subtype == SystemSubtype::Init
610    }
611
612    /// Check if this is a status message
613    pub fn is_status(&self) -> bool {
614        self.subtype == SystemSubtype::Status
615    }
616
617    /// Check if this is a compact_boundary message
618    pub fn is_compact_boundary(&self) -> bool {
619        self.subtype == SystemSubtype::CompactBoundary
620    }
621
622    /// Try to parse as an init message
623    pub fn as_init(&self) -> Option<InitMessage> {
624        if self.subtype != SystemSubtype::Init {
625            return None;
626        }
627        serde_json::from_value(self.data.clone()).ok()
628    }
629
630    /// Try to parse as a status message
631    pub fn as_status(&self) -> Option<StatusMessage> {
632        if self.subtype != SystemSubtype::Status {
633            return None;
634        }
635        serde_json::from_value(self.data.clone()).ok()
636    }
637
638    /// Try to parse as a compact_boundary message
639    pub fn as_compact_boundary(&self) -> Option<CompactBoundaryMessage> {
640        if self.subtype != SystemSubtype::CompactBoundary {
641            return None;
642        }
643        serde_json::from_value(self.data.clone()).ok()
644    }
645
646    /// Check if this is a task_started message
647    pub fn is_task_started(&self) -> bool {
648        self.subtype == SystemSubtype::TaskStarted
649    }
650
651    /// Check if this is a task_progress message
652    pub fn is_task_progress(&self) -> bool {
653        self.subtype == SystemSubtype::TaskProgress
654    }
655
656    /// Check if this is a task_notification message
657    pub fn is_task_notification(&self) -> bool {
658        self.subtype == SystemSubtype::TaskNotification
659    }
660
661    /// Try to parse as a task_started message
662    pub fn as_task_started(&self) -> Option<TaskStartedMessage> {
663        if self.subtype != SystemSubtype::TaskStarted {
664            return None;
665        }
666        serde_json::from_value(self.data.clone()).ok()
667    }
668
669    /// Try to parse as a task_progress message
670    pub fn as_task_progress(&self) -> Option<TaskProgressMessage> {
671        if self.subtype != SystemSubtype::TaskProgress {
672            return None;
673        }
674        serde_json::from_value(self.data.clone()).ok()
675    }
676
677    /// Try to parse as a task_notification message
678    pub fn as_task_notification(&self) -> Option<TaskNotificationMessage> {
679        if self.subtype != SystemSubtype::TaskNotification {
680            return None;
681        }
682        serde_json::from_value(self.data.clone()).ok()
683    }
684
685    /// Check if this is a task_updated message
686    pub fn is_task_updated(&self) -> bool {
687        self.subtype == SystemSubtype::TaskUpdated
688    }
689
690    /// Try to parse as a task_updated message
691    pub fn as_task_updated(&self) -> Option<TaskUpdatedMessage> {
692        if self.subtype != SystemSubtype::TaskUpdated {
693            return None;
694        }
695        serde_json::from_value(self.data.clone()).ok()
696    }
697
698    /// Check if this is a thinking_tokens message
699    pub fn is_thinking_tokens(&self) -> bool {
700        self.subtype == SystemSubtype::ThinkingTokens
701    }
702
703    /// Try to parse as a thinking_tokens message
704    pub fn as_thinking_tokens(&self) -> Option<ThinkingTokensMessage> {
705        if self.subtype != SystemSubtype::ThinkingTokens {
706            return None;
707        }
708        serde_json::from_value(self.data.clone()).ok()
709    }
710
711    /// Re-serialize this system message's payload through the typed view that
712    /// matches its `subtype`, returning the result as JSON.
713    ///
714    /// Used by the wrapping audit ([`crate::io::audit_frame`]) to verify that a
715    /// subtype's dedicated struct captures every wire field: the audit compares
716    /// this against the raw [`SystemMessage::data`]. Returns `None` for subtypes
717    /// this crate version has no dedicated struct for (including
718    /// [`SystemSubtype::Unknown`]) — those are reported as not fully wrapped.
719    pub fn typed_value(&self) -> Option<Value> {
720        fn reserialize<T: Serialize>(parsed: Option<T>) -> Option<Value> {
721            parsed.and_then(|v| serde_json::to_value(v).ok())
722        }
723        match self.subtype {
724            SystemSubtype::Init => reserialize(self.as_init()),
725            SystemSubtype::Status => reserialize(self.as_status()),
726            SystemSubtype::CompactBoundary => reserialize(self.as_compact_boundary()),
727            SystemSubtype::ThinkingTokens => reserialize(self.as_thinking_tokens()),
728            SystemSubtype::TaskStarted => reserialize(self.as_task_started()),
729            SystemSubtype::TaskProgress => reserialize(self.as_task_progress()),
730            SystemSubtype::TaskUpdated => reserialize(self.as_task_updated()),
731            SystemSubtype::TaskNotification => reserialize(self.as_task_notification()),
732            SystemSubtype::Unknown(_) => None,
733        }
734    }
735}
736
737/// Plugin info from the init message
738#[derive(Debug, Clone, Serialize, Deserialize)]
739pub struct PluginInfo {
740    /// Plugin name
741    pub name: String,
742    /// Path to the plugin on disk
743    pub path: String,
744    /// Plugin registry source (e.g., "rust-analyzer-lsp@claude-plugins-official")
745    #[serde(skip_serializing_if = "Option::is_none")]
746    pub source: Option<String>,
747}
748
749/// Init system message data - sent at session start
750#[derive(Debug, Clone, Serialize, Deserialize)]
751pub struct InitMessage {
752    /// Session identifier
753    pub session_id: String,
754    /// Current working directory
755    #[serde(skip_serializing_if = "Option::is_none")]
756    pub cwd: Option<String>,
757    /// Model being used
758    #[serde(skip_serializing_if = "Option::is_none")]
759    pub model: Option<String>,
760    /// List of available tools
761    #[serde(default, skip_serializing_if = "Vec::is_empty")]
762    pub tools: Vec<String>,
763    /// MCP servers configured
764    #[serde(default, skip_serializing_if = "Vec::is_empty")]
765    pub mcp_servers: Vec<Value>,
766    /// Available slash commands (e.g., "compact", "cost", "review")
767    #[serde(default, skip_serializing_if = "Vec::is_empty")]
768    pub slash_commands: Vec<String>,
769    /// Available agent types (e.g., "Bash", "Explore", "Plan")
770    #[serde(default, skip_serializing_if = "Vec::is_empty")]
771    pub agents: Vec<String>,
772    /// Installed plugins
773    #[serde(default, skip_serializing_if = "Vec::is_empty")]
774    pub plugins: Vec<PluginInfo>,
775    /// Installed skills
776    #[serde(default, skip_serializing_if = "Vec::is_empty")]
777    pub skills: Vec<Value>,
778    /// Claude Code CLI version
779    #[serde(skip_serializing_if = "Option::is_none")]
780    pub claude_code_version: Option<String>,
781    /// How the API key was sourced
782    #[serde(skip_serializing_if = "Option::is_none", rename = "apiKeySource")]
783    pub api_key_source: Option<ApiKeySource>,
784    /// Output style
785    #[serde(skip_serializing_if = "Option::is_none")]
786    pub output_style: Option<OutputStyle>,
787    /// Permission mode
788    #[serde(skip_serializing_if = "Option::is_none", rename = "permissionMode")]
789    pub permission_mode: Option<InitPermissionMode>,
790
791    /// Message-level unique identifier
792    #[serde(skip_serializing_if = "Option::is_none")]
793    pub uuid: Option<String>,
794
795    /// Memory storage paths (e.g., {"auto": "/path/to/memory/"})
796    #[serde(skip_serializing_if = "Option::is_none")]
797    pub memory_paths: Option<Value>,
798
799    /// Fast mode toggle state (e.g., "off")
800    #[serde(skip_serializing_if = "Option::is_none")]
801    pub fast_mode_state: Option<String>,
802
803    /// Whether analytics collection is disabled for this session.
804    #[serde(default, skip_serializing_if = "Option::is_none")]
805    pub analytics_disabled: Option<bool>,
806
807    /// Whether product-feedback prompts are disabled for this session.
808    #[serde(default, skip_serializing_if = "Option::is_none")]
809    pub product_feedback_disabled: Option<bool>,
810}
811
812/// Status system message - sent during operations like context compaction
813#[derive(Debug, Clone, Serialize, Deserialize)]
814pub struct StatusMessage {
815    /// Session identifier
816    pub session_id: String,
817    /// Current status (e.g., compacting) or null when complete
818    pub status: Option<StatusMessageStatus>,
819    /// Unique identifier for this message
820    #[serde(skip_serializing_if = "Option::is_none")]
821    pub uuid: Option<String>,
822}
823
824/// Compact boundary message - marks where context compaction occurred
825#[derive(Debug, Clone, Serialize, Deserialize)]
826pub struct CompactBoundaryMessage {
827    /// Session identifier
828    pub session_id: String,
829    /// Metadata about the compaction
830    pub compact_metadata: CompactMetadata,
831    /// Human-readable summary of what was compacted, when the CLI emits one.
832    ///
833    /// Also accepted under the `content` / `text` wire keys.
834    #[serde(
835        default,
836        skip_serializing_if = "Option::is_none",
837        alias = "content",
838        alias = "text"
839    )]
840    pub summary: Option<String>,
841    /// Number of messages summarized in this compaction pass, when present.
842    ///
843    /// Also accepted under the `message_count` wire key.
844    #[serde(
845        default,
846        skip_serializing_if = "Option::is_none",
847        alias = "message_count"
848    )]
849    pub leaf_message_count: Option<u32>,
850    /// Wall-clock duration of the compaction pass in milliseconds, when present.
851    #[serde(default, skip_serializing_if = "Option::is_none")]
852    pub duration_ms: Option<u64>,
853    /// Unique identifier for this message
854    #[serde(skip_serializing_if = "Option::is_none")]
855    pub uuid: Option<String>,
856}
857
858/// Metadata about context compaction
859#[derive(Debug, Clone, Serialize, Deserialize)]
860pub struct CompactMetadata {
861    /// Number of tokens before compaction
862    pub pre_tokens: u64,
863    /// What triggered the compaction
864    pub trigger: CompactionTrigger,
865}
866
867// ---------------------------------------------------------------------------
868// Task system message types (task_started, task_progress, task_notification)
869// ---------------------------------------------------------------------------
870
871/// Cumulative usage statistics for a background task.
872#[derive(Debug, Clone, Serialize, Deserialize)]
873pub struct TaskUsage {
874    /// Wall-clock milliseconds since the task started.
875    pub duration_ms: u64,
876    /// Total number of tool calls made so far.
877    pub tool_uses: u64,
878    /// Total tokens consumed so far.
879    pub total_tokens: u64,
880}
881
882/// The kind of background task.
883#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
884#[serde(rename_all = "snake_case")]
885pub enum TaskType {
886    /// A sub-agent task (e.g., Explore, Plan).
887    LocalAgent,
888    /// A background bash command.
889    LocalBash,
890}
891
892/// Completion status of a background task.
893#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
894#[serde(rename_all = "snake_case")]
895pub enum TaskStatus {
896    Completed,
897    Failed,
898}
899
900/// `task_started` system message — emitted once when a background task begins.
901#[derive(Debug, Clone, Serialize, Deserialize)]
902pub struct TaskStartedMessage {
903    pub session_id: String,
904    pub task_id: String,
905    pub task_type: TaskType,
906    pub tool_use_id: String,
907    pub description: String,
908    /// The subagent type for `local_agent` tasks (e.g. `general-purpose`,
909    /// `Explore`). Absent for `local_bash` tasks.
910    #[serde(default, skip_serializing_if = "Option::is_none")]
911    pub subagent_type: Option<String>,
912    /// The prompt handed to the subagent. Present for `local_agent` tasks.
913    #[serde(default, skip_serializing_if = "Option::is_none")]
914    pub prompt: Option<String>,
915    pub uuid: String,
916}
917
918/// `task_updated` system message — emitted when a background task's state
919/// changes (e.g. transitions to `completed`). Carries a partial `patch` of the
920/// fields that changed rather than the full task record.
921#[derive(Debug, Clone, Serialize, Deserialize)]
922pub struct TaskUpdatedMessage {
923    pub session_id: String,
924    pub task_id: String,
925    pub patch: TaskPatch,
926    pub uuid: String,
927}
928
929/// The partial update carried by a [`TaskUpdatedMessage`]. Every field is
930/// optional because the CLI only sends the keys that changed.
931#[derive(Debug, Clone, Default, Serialize, Deserialize)]
932pub struct TaskPatch {
933    #[serde(default, skip_serializing_if = "Option::is_none")]
934    pub status: Option<TaskStatus>,
935    /// Wall-clock epoch milliseconds when the task finished, when the patch
936    /// reports completion.
937    #[serde(default, skip_serializing_if = "Option::is_none")]
938    pub end_time: Option<u64>,
939}
940
941/// `thinking_tokens` system message — emitted as the model streams extended
942/// thinking, reporting the running estimate of thinking tokens consumed.
943#[derive(Debug, Clone, Serialize, Deserialize)]
944pub struct ThinkingTokensMessage {
945    pub session_id: String,
946    /// Running estimate of total thinking tokens for the current turn.
947    pub estimated_tokens: u64,
948    /// Increase in the estimate since the previous `thinking_tokens` event.
949    pub estimated_tokens_delta: u64,
950    pub uuid: String,
951}
952
953/// `task_progress` system message — emitted periodically as a background
954/// agent task executes tools. Not emitted for `local_bash` tasks.
955#[derive(Debug, Clone, Serialize, Deserialize)]
956pub struct TaskProgressMessage {
957    pub session_id: String,
958    pub task_id: String,
959    pub tool_use_id: String,
960    pub description: String,
961    pub last_tool_name: String,
962    pub usage: TaskUsage,
963    /// Subagent type for `local_agent` tasks (e.g. `Explore`).
964    #[serde(default, skip_serializing_if = "Option::is_none")]
965    pub subagent_type: Option<String>,
966    pub uuid: String,
967}
968
969/// `task_notification` system message — emitted once when a background
970/// task completes or fails.
971#[derive(Debug, Clone, Serialize, Deserialize)]
972pub struct TaskNotificationMessage {
973    pub session_id: String,
974    pub task_id: String,
975    pub status: TaskStatus,
976    pub summary: String,
977    pub output_file: Option<String>,
978    #[serde(skip_serializing_if = "Option::is_none")]
979    pub tool_use_id: Option<String>,
980    #[serde(skip_serializing_if = "Option::is_none")]
981    pub usage: Option<TaskUsage>,
982    #[serde(skip_serializing_if = "Option::is_none")]
983    pub uuid: Option<String>,
984}
985
986/// Assistant message
987#[derive(Debug, Clone, Serialize, Deserialize)]
988pub struct AssistantMessage {
989    pub message: AssistantMessageContent,
990    pub session_id: String,
991    #[serde(skip_serializing_if = "Option::is_none")]
992    pub uuid: Option<String>,
993    #[serde(skip_serializing_if = "Option::is_none")]
994    pub parent_tool_use_id: Option<String>,
995    /// Anthropic API request id that produced this message (e.g. `req_...`).
996    #[serde(skip_serializing_if = "Option::is_none")]
997    pub request_id: Option<String>,
998    /// Subagent type, when this assistant message was produced inside a
999    /// `local_agent` subagent (e.g. `general-purpose`, `Explore`).
1000    #[serde(skip_serializing_if = "Option::is_none")]
1001    pub subagent_type: Option<String>,
1002    /// Short description of the subagent task, present alongside `subagent_type`.
1003    #[serde(skip_serializing_if = "Option::is_none")]
1004    pub task_description: Option<String>,
1005}
1006
1007/// Nested message content for assistant messages
1008#[derive(Debug, Clone, Serialize, Deserialize)]
1009pub struct AssistantMessageContent {
1010    pub id: String,
1011    /// The Anthropic API message type — always `"message"`.
1012    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
1013    pub message_type: Option<String>,
1014    pub role: MessageRole,
1015    pub model: String,
1016    pub content: Vec<ContentBlock>,
1017    #[serde(skip_serializing_if = "Option::is_none")]
1018    pub stop_reason: Option<StopReason>,
1019    #[serde(skip_serializing_if = "Option::is_none")]
1020    pub stop_sequence: Option<String>,
1021    #[serde(skip_serializing_if = "Option::is_none")]
1022    pub usage: Option<AssistantUsage>,
1023    /// Details about why generation stopped
1024    #[serde(skip_serializing_if = "Option::is_none")]
1025    pub stop_details: Option<Value>,
1026    /// Context management metadata
1027    #[serde(skip_serializing_if = "Option::is_none")]
1028    pub context_management: Option<Value>,
1029}
1030
1031/// Usage information for assistant messages
1032#[derive(Debug, Clone, Serialize, Deserialize)]
1033pub struct AssistantUsage {
1034    /// Number of input tokens
1035    #[serde(default)]
1036    pub input_tokens: u32,
1037
1038    /// Number of output tokens
1039    #[serde(default)]
1040    pub output_tokens: u32,
1041
1042    /// Tokens used to create cache
1043    #[serde(default)]
1044    pub cache_creation_input_tokens: u32,
1045
1046    /// Tokens read from cache
1047    #[serde(default)]
1048    pub cache_read_input_tokens: u32,
1049
1050    /// Service tier used (e.g., "standard")
1051    #[serde(skip_serializing_if = "Option::is_none")]
1052    pub service_tier: Option<String>,
1053
1054    /// Detailed cache creation breakdown
1055    #[serde(skip_serializing_if = "Option::is_none")]
1056    pub cache_creation: Option<CacheCreationDetails>,
1057
1058    /// Inference geography (e.g., "not_available")
1059    #[serde(skip_serializing_if = "Option::is_none")]
1060    pub inference_geo: Option<String>,
1061}
1062
1063/// Detailed cache creation information
1064#[derive(Debug, Clone, Serialize, Deserialize)]
1065pub struct CacheCreationDetails {
1066    /// Ephemeral 1-hour input tokens
1067    #[serde(default)]
1068    pub ephemeral_1h_input_tokens: u32,
1069
1070    /// Ephemeral 5-minute input tokens
1071    #[serde(default)]
1072    pub ephemeral_5m_input_tokens: u32,
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077    use crate::io::ClaudeOutput;
1078
1079    #[test]
1080    fn test_system_message_init() {
1081        let json = r#"{
1082            "type": "system",
1083            "subtype": "init",
1084            "session_id": "test-session-123",
1085            "cwd": "/home/user/project",
1086            "model": "claude-sonnet-4",
1087            "tools": ["Bash", "Read", "Write"],
1088            "mcp_servers": [],
1089            "slash_commands": ["compact", "cost", "review"],
1090            "agents": ["Bash", "Explore", "Plan"],
1091            "plugins": [{"name": "rust-analyzer-lsp", "path": "/home/user/.claude/plugins/rust-analyzer-lsp/1.0.0"}],
1092            "skills": [],
1093            "claude_code_version": "2.1.15",
1094            "apiKeySource": "none",
1095            "output_style": "default",
1096            "permissionMode": "default"
1097        }"#;
1098
1099        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1100        if let ClaudeOutput::System(sys) = output {
1101            assert!(sys.is_init());
1102            assert!(!sys.is_status());
1103            assert!(!sys.is_compact_boundary());
1104
1105            let init = sys.as_init().expect("Should parse as init");
1106            assert_eq!(init.session_id, "test-session-123");
1107            assert_eq!(init.cwd, Some("/home/user/project".to_string()));
1108            assert_eq!(init.model, Some("claude-sonnet-4".to_string()));
1109            assert_eq!(init.tools, vec!["Bash", "Read", "Write"]);
1110            assert_eq!(init.slash_commands, vec!["compact", "cost", "review"]);
1111            assert_eq!(init.agents, vec!["Bash", "Explore", "Plan"]);
1112            assert_eq!(init.plugins.len(), 1);
1113            assert_eq!(init.plugins[0].name, "rust-analyzer-lsp");
1114            assert_eq!(init.claude_code_version, Some("2.1.15".to_string()));
1115            assert_eq!(init.api_key_source, Some(super::ApiKeySource::None));
1116            assert_eq!(init.output_style, Some(super::OutputStyle::Default));
1117            assert_eq!(
1118                init.permission_mode,
1119                Some(super::InitPermissionMode::Default)
1120            );
1121        } else {
1122            panic!("Expected System message");
1123        }
1124    }
1125
1126    #[test]
1127    fn test_system_message_init_from_real_capture() {
1128        let json = include_str!("../../test_cases/tool_use_captures/tool_msg_0.json");
1129        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1130        if let ClaudeOutput::System(sys) = output {
1131            let init = sys.as_init().expect("Should parse real init capture");
1132            assert_eq!(init.slash_commands.len(), 8);
1133            assert!(init.slash_commands.contains(&"compact".to_string()));
1134            assert!(init.slash_commands.contains(&"review".to_string()));
1135            assert_eq!(init.agents.len(), 5);
1136            assert!(init.agents.contains(&"Bash".to_string()));
1137            assert!(init.agents.contains(&"Explore".to_string()));
1138            assert_eq!(init.plugins.len(), 1);
1139            assert_eq!(init.plugins[0].name, "rust-analyzer-lsp");
1140            assert_eq!(init.claude_code_version, Some("2.1.15".to_string()));
1141        } else {
1142            panic!("Expected System message");
1143        }
1144    }
1145
1146    #[test]
1147    fn test_system_message_status() {
1148        let json = r#"{
1149            "type": "system",
1150            "subtype": "status",
1151            "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
1152            "status": "compacting",
1153            "uuid": "32eb9f9d-5ef7-47ff-8fce-bbe22fe7ed93"
1154        }"#;
1155
1156        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1157        if let ClaudeOutput::System(sys) = output {
1158            assert!(sys.is_status());
1159            assert!(!sys.is_init());
1160
1161            let status = sys.as_status().expect("Should parse as status");
1162            assert_eq!(status.session_id, "879c1a88-3756-4092-aa95-0020c4ed9692");
1163            assert_eq!(status.status, Some(super::StatusMessageStatus::Compacting));
1164            assert_eq!(
1165                status.uuid,
1166                Some("32eb9f9d-5ef7-47ff-8fce-bbe22fe7ed93".to_string())
1167            );
1168        } else {
1169            panic!("Expected System message");
1170        }
1171    }
1172
1173    #[test]
1174    fn test_system_message_status_null() {
1175        let json = r#"{
1176            "type": "system",
1177            "subtype": "status",
1178            "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
1179            "status": null,
1180            "uuid": "92d9637e-d00e-418e-acd2-a504e3861c6a"
1181        }"#;
1182
1183        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1184        if let ClaudeOutput::System(sys) = output {
1185            let status = sys.as_status().expect("Should parse as status");
1186            assert_eq!(status.status, None);
1187        } else {
1188            panic!("Expected System message");
1189        }
1190    }
1191
1192    #[test]
1193    fn test_system_message_task_started() {
1194        let json = r#"{
1195            "type": "system",
1196            "subtype": "task_started",
1197            "session_id": "9abbc466-dad0-4b8e-b6b0-cad5eb7a16b9",
1198            "task_id": "b6daf3f",
1199            "task_type": "local_bash",
1200            "tool_use_id": "toolu_011rfSTFumpJZdCCfzeD7jaS",
1201            "description": "Wait for CI on PR #12",
1202            "uuid": "c4243261-c128-4747-b8c3-5e1c7c10eeb8"
1203        }"#;
1204
1205        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1206        if let ClaudeOutput::System(sys) = output {
1207            assert!(sys.is_task_started());
1208            assert!(!sys.is_task_progress());
1209            assert!(!sys.is_task_notification());
1210
1211            let task = sys.as_task_started().expect("Should parse as task_started");
1212            assert_eq!(task.session_id, "9abbc466-dad0-4b8e-b6b0-cad5eb7a16b9");
1213            assert_eq!(task.task_id, "b6daf3f");
1214            assert_eq!(task.task_type, super::TaskType::LocalBash);
1215            assert_eq!(task.tool_use_id, "toolu_011rfSTFumpJZdCCfzeD7jaS");
1216            assert_eq!(task.description, "Wait for CI on PR #12");
1217        } else {
1218            panic!("Expected System message");
1219        }
1220    }
1221
1222    #[test]
1223    fn test_system_message_task_started_agent() {
1224        let json = r#"{
1225            "type": "system",
1226            "subtype": "task_started",
1227            "session_id": "bff4f716-17c1-4255-ab7b-eea9d33824e3",
1228            "task_id": "a4a7e0906e5fc64cc",
1229            "task_type": "local_agent",
1230            "tool_use_id": "toolu_01SFz9FwZ1cYgCSy8vRM7wep",
1231            "description": "Explore Scene/ArrayScene duplication",
1232            "uuid": "85a39f5a-e4d4-47f7-9a6d-1125f1a8035f"
1233        }"#;
1234
1235        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1236        if let ClaudeOutput::System(sys) = output {
1237            let task = sys.as_task_started().expect("Should parse as task_started");
1238            assert_eq!(task.task_type, super::TaskType::LocalAgent);
1239            assert_eq!(task.task_id, "a4a7e0906e5fc64cc");
1240        } else {
1241            panic!("Expected System message");
1242        }
1243    }
1244
1245    #[test]
1246    fn test_system_message_task_progress() {
1247        let json = r#"{
1248            "type": "system",
1249            "subtype": "task_progress",
1250            "session_id": "bff4f716-17c1-4255-ab7b-eea9d33824e3",
1251            "task_id": "a4a7e0906e5fc64cc",
1252            "tool_use_id": "toolu_01SFz9FwZ1cYgCSy8vRM7wep",
1253            "description": "Reading src/jplephem/chebyshev.rs",
1254            "last_tool_name": "Read",
1255            "usage": {
1256                "duration_ms": 13996,
1257                "tool_uses": 9,
1258                "total_tokens": 38779
1259            },
1260            "uuid": "85a39f5a-e4d4-47f7-9a6d-1125f1a8035f"
1261        }"#;
1262
1263        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1264        if let ClaudeOutput::System(sys) = output {
1265            assert!(sys.is_task_progress());
1266            assert!(!sys.is_task_started());
1267
1268            let progress = sys
1269                .as_task_progress()
1270                .expect("Should parse as task_progress");
1271            assert_eq!(progress.task_id, "a4a7e0906e5fc64cc");
1272            assert_eq!(progress.description, "Reading src/jplephem/chebyshev.rs");
1273            assert_eq!(progress.last_tool_name, "Read");
1274            assert_eq!(progress.usage.duration_ms, 13996);
1275            assert_eq!(progress.usage.tool_uses, 9);
1276            assert_eq!(progress.usage.total_tokens, 38779);
1277        } else {
1278            panic!("Expected System message");
1279        }
1280    }
1281
1282    #[test]
1283    fn test_system_message_task_notification_completed() {
1284        let json = r#"{
1285            "type": "system",
1286            "subtype": "task_notification",
1287            "session_id": "bff4f716-17c1-4255-ab7b-eea9d33824e3",
1288            "task_id": "a0ba761e9dc9c316f",
1289            "tool_use_id": "toolu_01Ho6XVXFLVNjTQ9YqowdBXW",
1290            "status": "completed",
1291            "summary": "Agent \"Write Hipparcos data source doc\" completed",
1292            "output_file": "",
1293            "usage": {
1294                "duration_ms": 172300,
1295                "tool_uses": 11,
1296                "total_tokens": 42005
1297            },
1298            "uuid": "269f49b9-218d-4c8d-9f7e-3a5383a0c5b2"
1299        }"#;
1300
1301        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1302        if let ClaudeOutput::System(sys) = output {
1303            assert!(sys.is_task_notification());
1304
1305            let notif = sys
1306                .as_task_notification()
1307                .expect("Should parse as task_notification");
1308            assert_eq!(notif.status, super::TaskStatus::Completed);
1309            assert_eq!(
1310                notif.summary,
1311                "Agent \"Write Hipparcos data source doc\" completed"
1312            );
1313            assert_eq!(notif.output_file, Some("".to_string()));
1314            assert_eq!(
1315                notif.tool_use_id,
1316                Some("toolu_01Ho6XVXFLVNjTQ9YqowdBXW".to_string())
1317            );
1318            let usage = notif.usage.expect("Should have usage");
1319            assert_eq!(usage.duration_ms, 172300);
1320            assert_eq!(usage.tool_uses, 11);
1321            assert_eq!(usage.total_tokens, 42005);
1322        } else {
1323            panic!("Expected System message");
1324        }
1325    }
1326
1327    #[test]
1328    fn test_system_message_task_notification_failed_no_usage() {
1329        let json = r#"{
1330            "type": "system",
1331            "subtype": "task_notification",
1332            "session_id": "ea629737-3c36-48a8-a1c4-ad761ad35784",
1333            "task_id": "b98f6a3",
1334            "status": "failed",
1335            "summary": "Background command \"Run FSM calibration\" failed with exit code 1",
1336            "output_file": "/tmp/claude-1000/tasks/b98f6a3.output"
1337        }"#;
1338
1339        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1340        if let ClaudeOutput::System(sys) = output {
1341            let notif = sys
1342                .as_task_notification()
1343                .expect("Should parse as task_notification");
1344            assert_eq!(notif.status, super::TaskStatus::Failed);
1345            assert!(notif.tool_use_id.is_none());
1346            assert!(notif.usage.is_none());
1347            assert_eq!(
1348                notif.output_file,
1349                Some("/tmp/claude-1000/tasks/b98f6a3.output".to_string())
1350            );
1351        } else {
1352            panic!("Expected System message");
1353        }
1354    }
1355
1356    /// Task system messages survive a `to_value` → `from_value` round-trip
1357    /// with their typed accessors still resolving. Mirrors the proxy/relay
1358    /// path where output is reparsed from a `serde_json::Value` rather than
1359    /// straight from the CLI's stdout, so a silently dropped or renamed field
1360    /// surfaces here instead of as a `None` downstream.
1361    #[test]
1362    fn test_task_messages_roundtrip_through_value() {
1363        let cases = [
1364            r#"{"type":"system","subtype":"task_started","session_id":"s1",
1365                "task_id":"t1","task_type":"local_bash","tool_use_id":"tu1",
1366                "description":"Sleep 3s","uuid":"u1"}"#,
1367            r#"{"type":"system","subtype":"task_progress","session_id":"s1",
1368                "task_id":"t1","tool_use_id":"tu1","description":"Running ls",
1369                "last_tool_name":"Bash",
1370                "usage":{"duration_ms":100,"tool_uses":1,"total_tokens":500},
1371                "uuid":"u2"}"#,
1372            r#"{"type":"system","subtype":"task_notification","session_id":"s1",
1373                "task_id":"t1","tool_use_id":"tu1","status":"completed",
1374                "summary":"done","output_file":"",
1375                "usage":{"duration_ms":100,"tool_uses":1,"total_tokens":500},
1376                "uuid":"u3"}"#,
1377        ];
1378
1379        for json in cases {
1380            let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1381            let value = serde_json::to_value(&output).unwrap();
1382            let reparsed: ClaudeOutput = serde_json::from_value(value).unwrap();
1383
1384            let ClaudeOutput::System(sys) = reparsed else {
1385                panic!("Expected System variant after round-trip");
1386            };
1387
1388            match sys.subtype {
1389                super::SystemSubtype::TaskStarted => {
1390                    assert!(
1391                        sys.as_task_started().is_some(),
1392                        "as_task_started failed after round-trip"
1393                    );
1394                }
1395                super::SystemSubtype::TaskProgress => {
1396                    assert!(
1397                        sys.as_task_progress().is_some(),
1398                        "as_task_progress failed after round-trip"
1399                    );
1400                }
1401                super::SystemSubtype::TaskNotification => {
1402                    assert!(
1403                        sys.as_task_notification().is_some(),
1404                        "as_task_notification failed after round-trip"
1405                    );
1406                }
1407                other => panic!("unexpected subtype after round-trip: {other:?}"),
1408            }
1409        }
1410    }
1411
1412    #[test]
1413    fn test_system_message_compact_boundary() {
1414        let json = r#"{
1415            "type": "system",
1416            "subtype": "compact_boundary",
1417            "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
1418            "compact_metadata": {
1419                "pre_tokens": 155285,
1420                "trigger": "auto"
1421            },
1422            "uuid": "a67780d5-74cb-48b1-9137-7a6e7cee45d7"
1423        }"#;
1424
1425        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1426        if let ClaudeOutput::System(sys) = output {
1427            assert!(sys.is_compact_boundary());
1428            assert!(!sys.is_init());
1429            assert!(!sys.is_status());
1430
1431            let compact = sys
1432                .as_compact_boundary()
1433                .expect("Should parse as compact_boundary");
1434            assert_eq!(compact.session_id, "879c1a88-3756-4092-aa95-0020c4ed9692");
1435            assert_eq!(compact.compact_metadata.pre_tokens, 155285);
1436            assert_eq!(
1437                compact.compact_metadata.trigger,
1438                super::CompactionTrigger::Auto
1439            );
1440            // Per-compaction stats are optional and absent here.
1441            assert!(compact.summary.is_none());
1442            assert!(compact.leaf_message_count.is_none());
1443            assert!(compact.duration_ms.is_none());
1444        } else {
1445            panic!("Expected System message");
1446        }
1447    }
1448
1449    #[test]
1450    fn test_compact_boundary_with_summary_stats() {
1451        // Canonical keys.
1452        let json = r#"{
1453            "type": "system",
1454            "subtype": "compact_boundary",
1455            "session_id": "s1",
1456            "compact_metadata": { "pre_tokens": 1000, "trigger": "manual" },
1457            "summary": "Summarized the earlier exploration.",
1458            "leaf_message_count": 42,
1459            "duration_ms": 1234,
1460            "uuid": "u1"
1461        }"#;
1462        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1463        let ClaudeOutput::System(sys) = output else {
1464            panic!("Expected System message");
1465        };
1466        let compact = sys.as_compact_boundary().expect("compact_boundary");
1467        assert_eq!(
1468            compact.summary.as_deref(),
1469            Some("Summarized the earlier exploration.")
1470        );
1471        assert_eq!(compact.leaf_message_count, Some(42));
1472        assert_eq!(compact.duration_ms, Some(1234));
1473
1474        // Alternate wire keys (`content` for summary, `message_count` for count)
1475        // deserialize into the same fields.
1476        let json_alt = r#"{
1477            "type": "system",
1478            "subtype": "compact_boundary",
1479            "session_id": "s2",
1480            "compact_metadata": { "pre_tokens": 2000, "trigger": "auto" },
1481            "content": "alt-key summary",
1482            "message_count": 7
1483        }"#;
1484        let output: ClaudeOutput = serde_json::from_str(json_alt).unwrap();
1485        let ClaudeOutput::System(sys) = output else {
1486            panic!("Expected System message");
1487        };
1488        let compact = sys.as_compact_boundary().expect("compact_boundary");
1489        assert_eq!(compact.summary.as_deref(), Some("alt-key summary"));
1490        assert_eq!(compact.leaf_message_count, Some(7));
1491    }
1492
1493    #[test]
1494    fn test_init_message_with_new_fields() {
1495        let json = r#"{
1496            "type": "system",
1497            "subtype": "init",
1498            "session_id": "test-session",
1499            "cwd": "/home/user",
1500            "model": "claude-opus-4-7",
1501            "tools": ["Bash"],
1502            "mcp_servers": [],
1503            "permissionMode": "default",
1504            "apiKeySource": "none",
1505            "uuid": "44841a0d-182d-493a-86b5-79800d3d9665",
1506            "memory_paths": {"auto": "/home/user/.claude/projects/memory/"},
1507            "fast_mode_state": "off",
1508            "plugins": [{"name": "lsp", "path": "/plugins/lsp", "source": "lsp@official"}],
1509            "claude_code_version": "2.1.117"
1510        }"#;
1511
1512        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1513        if let ClaudeOutput::System(sys) = output {
1514            let init = sys.as_init().expect("Should parse as init");
1515            assert_eq!(
1516                init.uuid.as_deref(),
1517                Some("44841a0d-182d-493a-86b5-79800d3d9665")
1518            );
1519            assert!(init.memory_paths.is_some());
1520            assert_eq!(init.fast_mode_state.as_deref(), Some("off"));
1521            assert_eq!(init.plugins[0].source.as_deref(), Some("lsp@official"));
1522            assert_eq!(init.claude_code_version.as_deref(), Some("2.1.117"));
1523        } else {
1524            panic!("Expected System message");
1525        }
1526    }
1527
1528    #[test]
1529    fn test_assistant_message_with_new_fields() {
1530        let json = r#"{
1531            "type": "assistant",
1532            "message": {
1533                "id": "msg_1",
1534                "type": "message",
1535                "role": "assistant",
1536                "model": "claude-opus-4-7",
1537                "content": [{"type": "text", "text": "Hello"}],
1538                "stop_reason": "end_turn",
1539                "stop_details": null,
1540                "context_management": null,
1541                "usage": {
1542                    "input_tokens": 100,
1543                    "output_tokens": 10,
1544                    "cache_creation_input_tokens": 50,
1545                    "cache_read_input_tokens": 0,
1546                    "service_tier": "standard",
1547                    "inference_geo": "not_available"
1548                }
1549            },
1550            "session_id": "abc",
1551            "uuid": "msg-uuid-123"
1552        }"#;
1553
1554        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1555        if let ClaudeOutput::Assistant(asst) = output {
1556            assert_eq!(asst.message.stop_details, None);
1557            assert_eq!(asst.message.context_management, None);
1558            let usage = asst.message.usage.unwrap();
1559            assert_eq!(usage.inference_geo.as_deref(), Some("not_available"));
1560        } else {
1561            panic!("Expected Assistant message");
1562        }
1563    }
1564
1565    #[test]
1566    fn test_user_message_with_new_fields() {
1567        let json = r#"{
1568            "type": "user",
1569            "message": {
1570                "role": "user",
1571                "content": [{"type": "text", "text": "Hello"}]
1572            },
1573            "session_id": "9abbc466-dad0-4b8e-b6b0-cad5eb7a16b9",
1574            "parent_tool_use_id": "toolu_123",
1575            "uuid": "user-msg-456"
1576        }"#;
1577
1578        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1579        if let ClaudeOutput::User(user) = output {
1580            assert_eq!(user.parent_tool_use_id.as_deref(), Some("toolu_123"));
1581            assert_eq!(user.uuid.as_deref(), Some("user-msg-456"));
1582        } else {
1583            panic!("Expected User message");
1584        }
1585    }
1586
1587    /// Real wire payload captured from the CLI after answering an
1588    /// AskUserQuestion via the permission control protocol. The top-level
1589    /// `tool_use_result` and `timestamp` fields must round-trip without loss —
1590    /// proxies using this crate to relay messages to a viewer rely on those
1591    /// fields being preserved (the viewer reads `tool_use_result.answers`).
1592    #[test]
1593    fn test_user_message_preserves_tool_use_result_and_timestamp() {
1594        let json = r#"{
1595            "type":"user",
1596            "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"}]},
1597            "parent_tool_use_id":null,
1598            "session_id":"622ae0c3-3d50-4fa7-9ee0-69d691238c6d",
1599            "uuid":"8ef6e997-a849-4d15-bed3-2837c3d3f4cd",
1600            "timestamp":"2026-05-12T23:12:04.121Z",
1601            "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"}}
1602        }"#;
1603
1604        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1605        let user = match output {
1606            ClaudeOutput::User(u) => u,
1607            other => panic!("Expected User message, got {:?}", other.message_type()),
1608        };
1609
1610        assert_eq!(user.timestamp.as_deref(), Some("2026-05-12T23:12:04.121Z"));
1611        let raw = user
1612            .tool_use_result
1613            .as_ref()
1614            .expect("tool_use_result must be captured");
1615        assert_eq!(raw["answers"]["Color"], "Blue");
1616        assert_eq!(raw["questions"][0]["header"], "Color");
1617
1618        // Round-trip: re-serialize and confirm tool_use_result + timestamp
1619        // survive — the bug we're guarding against is that the proxy silently
1620        // drops these fields when relaying user messages.
1621        let reser: serde_json::Value = serde_json::to_value(&user).unwrap();
1622        assert_eq!(reser["timestamp"], "2026-05-12T23:12:04.121Z");
1623        assert_eq!(reser["tool_use_result"]["answers"]["Color"], "Blue");
1624        assert_eq!(
1625            reser["tool_use_result"]["questions"][0]["question"],
1626            "Which color do you prefer?"
1627        );
1628
1629        // Typed accessor: AskUserQuestionInput has the same shape as the
1630        // AskUserQuestion tool_use_result.
1631        let typed: crate::AskUserQuestionInput = user
1632            .tool_use_result_as::<crate::AskUserQuestionInput>()
1633            .expect("tool_use_result present")
1634            .expect("AskUserQuestionInput parses");
1635        assert_eq!(typed.questions.len(), 1);
1636        assert_eq!(typed.questions[0].header, "Color");
1637        let answers = typed.answers.expect("answers populated");
1638        assert_eq!(answers.get("Color").map(String::as_str), Some("Blue"));
1639    }
1640
1641    /// User messages without `tool_use_result` / `timestamp` must still
1642    /// deserialize fine and serialize back without spuriously emitting nulls.
1643    #[test]
1644    fn test_user_message_without_tool_use_result_omits_field() {
1645        let json = r#"{
1646            "type":"user",
1647            "message":{"role":"user","content":[{"type":"text","text":"hello"}]},
1648            "session_id":"622ae0c3-3d50-4fa7-9ee0-69d691238c6d"
1649        }"#;
1650
1651        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1652        let user = match output {
1653            ClaudeOutput::User(u) => u,
1654            _ => panic!("Expected User message"),
1655        };
1656        assert!(user.tool_use_result.is_none());
1657        assert!(user.timestamp.is_none());
1658
1659        let reser = serde_json::to_value(&user).unwrap();
1660        assert!(reser.get("tool_use_result").is_none());
1661        assert!(reser.get("timestamp").is_none());
1662    }
1663
1664    /// A `Task` tool result must expose subagent token / timing / tool-use
1665    /// accounting through the typed [`UserMessage::subagent_result`] accessor,
1666    /// including the nested per-model `usage` breakdown and `toolStats`.
1667    #[test]
1668    fn test_subagent_result_exposes_token_accounting() {
1669        let json = r#"{
1670            "type":"user",
1671            "message":{"role":"user","content":[{"tool_use_id":"toolu_01","type":"tool_result","content":[{"type":"text","text":"21"}]}]},
1672            "session_id":"d3fc5942-75e5-4aa1-a87d-b9484a176541",
1673            "tool_use_result":{
1674                "status":"completed",
1675                "prompt":"Count the .rs files.",
1676                "agentId":"ac4f0276e9d4b6232",
1677                "agentType":"Explore",
1678                "content":[{"type":"text","text":"21"}],
1679                "resolvedModel":"claude-haiku-4-5-20251001",
1680                "totalDurationMs":6869,
1681                "totalTokens":7834,
1682                "totalToolUseCount":1,
1683                "usage":{"input_tokens":6,"cache_creation_input_tokens":125,"cache_read_input_tokens":7699,"output_tokens":4,"service_tier":"standard"},
1684                "toolStats":{"readCount":0,"searchCount":0,"bashCount":1,"editFileCount":0,"linesAdded":0,"linesRemoved":0,"otherToolCount":0}
1685            }
1686        }"#;
1687
1688        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1689        let user = match output {
1690            ClaudeOutput::User(u) => u,
1691            _ => panic!("Expected User message"),
1692        };
1693
1694        let result = user.subagent_result().expect("subagent result parses");
1695        assert_eq!(result.agent_type.as_deref(), Some("Explore"));
1696        assert_eq!(
1697            result.resolved_model.as_deref(),
1698            Some("claude-haiku-4-5-20251001")
1699        );
1700        assert_eq!(result.total_tokens, Some(7834));
1701        assert_eq!(result.total_duration_ms, Some(6869));
1702        assert_eq!(result.total_tool_use_count, Some(1));
1703
1704        let usage = result.usage.expect("nested usage present");
1705        assert_eq!(usage.input_tokens, 6);
1706        assert_eq!(usage.cache_read_input_tokens, 7699);
1707
1708        let stats = result.tool_stats.expect("toolStats present");
1709        assert_eq!(stats.bash_count, 1);
1710    }
1711
1712    /// `tool_use_result` shapes that aren't subagent runs (e.g. AskUserQuestion)
1713    /// parse leniently into the all-`Option` [`SubagentResult`] with empty
1714    /// accounting rather than failing, so callers can probe without panicking.
1715    #[test]
1716    fn test_subagent_result_absent_for_non_task_result() {
1717        let json = r#"{
1718            "type":"user",
1719            "message":{"role":"user","content":[{"type":"text","text":"hi"}]},
1720            "session_id":"622ae0c3-3d50-4fa7-9ee0-69d691238c6d",
1721            "tool_use_result":{"questions":[],"answers":{"Color":"Blue"}}
1722        }"#;
1723
1724        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1725        let user = match output {
1726            ClaudeOutput::User(u) => u,
1727            _ => panic!("Expected User message"),
1728        };
1729
1730        let result = user.subagent_result().expect("lenient parse");
1731        assert_eq!(result.total_tokens, None);
1732        assert_eq!(result.agent_type, None);
1733    }
1734}