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::claude_output::ClaudeOutput;
7use super::content_blocks::{deserialize_content_blocks, ContentBlock};
8
9/// Known system message subtypes.
10///
11/// The Claude CLI emits system messages with a `subtype` field indicating what
12/// kind of system event occurred. This enum captures the known subtypes while
13/// preserving unknown values via the `Unknown` variant for forward compatibility.
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
15pub enum SystemSubtype {
16    Init,
17    Status,
18    CompactBoundary,
19    ThinkingTokens,
20    TaskStarted,
21    TaskProgress,
22    TaskUpdated,
23    TaskNotification,
24    ApiRetry,
25    ControlRequestProgress,
26    ModelRefusalFallback,
27    ModelRefusalNoFallback,
28    LocalCommandOutput,
29    HookStarted,
30    HookProgress,
31    HookResponse,
32    PluginInstall,
33    BackgroundTasksChanged,
34    SessionStateChanged,
35    WorkerShuttingDown,
36    CommandsChanged,
37    Notification,
38    FilesPersisted,
39    MemoryRecall,
40    ElicitationComplete,
41    PermissionDenied,
42    MirrorError,
43    Informational,
44    CodeChangePublished,
45    VcsStateChanged,
46    FeedbackDraftQueued,
47    CloudSessionDelta,
48    /// A subtype not yet known to this version of the crate.
49    Unknown(String),
50}
51
52impl SystemSubtype {
53    pub fn as_str(&self) -> &str {
54        match self {
55            Self::Init => "init",
56            Self::Status => "status",
57            Self::CompactBoundary => "compact_boundary",
58            Self::ThinkingTokens => "thinking_tokens",
59            Self::TaskStarted => "task_started",
60            Self::TaskProgress => "task_progress",
61            Self::TaskUpdated => "task_updated",
62            Self::TaskNotification => "task_notification",
63            Self::ApiRetry => "api_retry",
64            Self::ControlRequestProgress => "control_request_progress",
65            Self::ModelRefusalFallback => "model_refusal_fallback",
66            Self::ModelRefusalNoFallback => "model_refusal_no_fallback",
67            Self::LocalCommandOutput => "local_command_output",
68            Self::HookStarted => "hook_started",
69            Self::HookProgress => "hook_progress",
70            Self::HookResponse => "hook_response",
71            Self::PluginInstall => "plugin_install",
72            Self::BackgroundTasksChanged => "background_tasks_changed",
73            Self::SessionStateChanged => "session_state_changed",
74            Self::WorkerShuttingDown => "worker_shutting_down",
75            Self::CommandsChanged => "commands_changed",
76            Self::Notification => "notification",
77            Self::FilesPersisted => "files_persisted",
78            Self::MemoryRecall => "memory_recall",
79            Self::ElicitationComplete => "elicitation_complete",
80            Self::PermissionDenied => "permission_denied",
81            Self::MirrorError => "mirror_error",
82            Self::Informational => "informational",
83            Self::CodeChangePublished => "code_change_published",
84            Self::VcsStateChanged => "vcs_state_changed",
85            Self::FeedbackDraftQueued => "feedback_draft_queued",
86            Self::CloudSessionDelta => "cloud_session_delta",
87            Self::Unknown(s) => s.as_str(),
88        }
89    }
90}
91
92impl fmt::Display for SystemSubtype {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        f.write_str(self.as_str())
95    }
96}
97
98impl From<&str> for SystemSubtype {
99    fn from(s: &str) -> Self {
100        match s {
101            "init" => Self::Init,
102            "status" => Self::Status,
103            "compact_boundary" => Self::CompactBoundary,
104            "thinking_tokens" => Self::ThinkingTokens,
105            "task_started" => Self::TaskStarted,
106            "task_progress" => Self::TaskProgress,
107            "task_updated" => Self::TaskUpdated,
108            "task_notification" => Self::TaskNotification,
109            "api_retry" => Self::ApiRetry,
110            "control_request_progress" => Self::ControlRequestProgress,
111            "model_refusal_fallback" => Self::ModelRefusalFallback,
112            "model_refusal_no_fallback" => Self::ModelRefusalNoFallback,
113            "local_command_output" => Self::LocalCommandOutput,
114            "hook_started" => Self::HookStarted,
115            "hook_progress" => Self::HookProgress,
116            "hook_response" => Self::HookResponse,
117            "plugin_install" => Self::PluginInstall,
118            "background_tasks_changed" => Self::BackgroundTasksChanged,
119            "session_state_changed" => Self::SessionStateChanged,
120            "worker_shutting_down" => Self::WorkerShuttingDown,
121            "commands_changed" => Self::CommandsChanged,
122            "notification" => Self::Notification,
123            "files_persisted" => Self::FilesPersisted,
124            "memory_recall" => Self::MemoryRecall,
125            "elicitation_complete" => Self::ElicitationComplete,
126            "permission_denied" => Self::PermissionDenied,
127            "mirror_error" => Self::MirrorError,
128            "informational" => Self::Informational,
129            "code_change_published" => Self::CodeChangePublished,
130            "vcs_state_changed" => Self::VcsStateChanged,
131            "feedback_draft_queued" => Self::FeedbackDraftQueued,
132            "cloud_session_delta" => Self::CloudSessionDelta,
133            other => Self::Unknown(other.to_string()),
134        }
135    }
136}
137
138impl Serialize for SystemSubtype {
139    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
140        serializer.serialize_str(self.as_str())
141    }
142}
143
144impl<'de> Deserialize<'de> for SystemSubtype {
145    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
146        let s = String::deserialize(deserializer)?;
147        Ok(Self::from(s.as_str()))
148    }
149}
150
151/// Known message roles.
152///
153/// Used in `MessageContent` and `AssistantMessageContent` to indicate the
154/// speaker of a message.
155#[derive(Debug, Clone, PartialEq, Eq, Hash)]
156pub enum MessageRole {
157    User,
158    Assistant,
159    /// A role not yet known to this version of the crate.
160    Unknown(String),
161}
162
163impl MessageRole {
164    pub fn as_str(&self) -> &str {
165        match self {
166            Self::User => "user",
167            Self::Assistant => "assistant",
168            Self::Unknown(s) => s.as_str(),
169        }
170    }
171}
172
173impl fmt::Display for MessageRole {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        f.write_str(self.as_str())
176    }
177}
178
179impl From<&str> for MessageRole {
180    fn from(s: &str) -> Self {
181        match s {
182            "user" => Self::User,
183            "assistant" => Self::Assistant,
184            other => Self::Unknown(other.to_string()),
185        }
186    }
187}
188
189impl Serialize for MessageRole {
190    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
191        serializer.serialize_str(self.as_str())
192    }
193}
194
195impl<'de> Deserialize<'de> for MessageRole {
196    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
197        let s = String::deserialize(deserializer)?;
198        Ok(Self::from(s.as_str()))
199    }
200}
201
202/// What triggered a context compaction.
203#[derive(Debug, Clone, PartialEq, Eq, Hash)]
204pub enum CompactionTrigger {
205    /// Automatic compaction triggered by token limit.
206    Auto,
207    /// User-initiated compaction (e.g., /compact command).
208    Manual,
209    /// A trigger not yet known to this version of the crate.
210    Unknown(String),
211}
212
213impl CompactionTrigger {
214    pub fn as_str(&self) -> &str {
215        match self {
216            Self::Auto => "auto",
217            Self::Manual => "manual",
218            Self::Unknown(s) => s.as_str(),
219        }
220    }
221}
222
223impl fmt::Display for CompactionTrigger {
224    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225        f.write_str(self.as_str())
226    }
227}
228
229impl From<&str> for CompactionTrigger {
230    fn from(s: &str) -> Self {
231        match s {
232            "auto" => Self::Auto,
233            "manual" => Self::Manual,
234            other => Self::Unknown(other.to_string()),
235        }
236    }
237}
238
239impl Serialize for CompactionTrigger {
240    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
241        serializer.serialize_str(self.as_str())
242    }
243}
244
245impl<'de> Deserialize<'de> for CompactionTrigger {
246    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
247        let s = String::deserialize(deserializer)?;
248        Ok(Self::from(s.as_str()))
249    }
250}
251
252/// Reason why the assistant stopped generating.
253#[derive(Debug, Clone, PartialEq, Eq, Hash)]
254pub enum StopReason {
255    /// The assistant reached a natural end of its turn.
256    EndTurn,
257    /// The response hit the maximum token limit.
258    MaxTokens,
259    /// The assistant wants to use a tool.
260    ToolUse,
261    /// A stop reason not yet known to this version of the crate.
262    Unknown(String),
263}
264
265impl StopReason {
266    pub fn as_str(&self) -> &str {
267        match self {
268            Self::EndTurn => "end_turn",
269            Self::MaxTokens => "max_tokens",
270            Self::ToolUse => "tool_use",
271            Self::Unknown(s) => s.as_str(),
272        }
273    }
274}
275
276impl fmt::Display for StopReason {
277    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
278        f.write_str(self.as_str())
279    }
280}
281
282impl From<&str> for StopReason {
283    fn from(s: &str) -> Self {
284        match s {
285            "end_turn" => Self::EndTurn,
286            "max_tokens" => Self::MaxTokens,
287            "tool_use" => Self::ToolUse,
288            other => Self::Unknown(other.to_string()),
289        }
290    }
291}
292
293impl Serialize for StopReason {
294    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
295        serializer.serialize_str(self.as_str())
296    }
297}
298
299impl<'de> Deserialize<'de> for StopReason {
300    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
301        let s = String::deserialize(deserializer)?;
302        Ok(Self::from(s.as_str()))
303    }
304}
305
306/// How the API key was sourced for the session.
307#[derive(Debug, Clone, PartialEq, Eq, Hash)]
308pub enum ApiKeySource {
309    /// No API key provided.
310    None,
311    User,
312    Project,
313    Org,
314    Temporary,
315    Oauth,
316    /// A source not yet known to this version of the crate.
317    Unknown(String),
318}
319
320impl ApiKeySource {
321    pub fn as_str(&self) -> &str {
322        match self {
323            Self::None => "none",
324            Self::User => "user",
325            Self::Project => "project",
326            Self::Org => "org",
327            Self::Temporary => "temporary",
328            Self::Oauth => "oauth",
329            Self::Unknown(s) => s.as_str(),
330        }
331    }
332}
333
334impl fmt::Display for ApiKeySource {
335    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
336        f.write_str(self.as_str())
337    }
338}
339
340impl From<&str> for ApiKeySource {
341    fn from(s: &str) -> Self {
342        match s {
343            "none" => Self::None,
344            "user" => Self::User,
345            "project" => Self::Project,
346            "org" => Self::Org,
347            "temporary" => Self::Temporary,
348            "oauth" => Self::Oauth,
349            other => Self::Unknown(other.to_string()),
350        }
351    }
352}
353
354impl Serialize for ApiKeySource {
355    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
356        serializer.serialize_str(self.as_str())
357    }
358}
359
360impl<'de> Deserialize<'de> for ApiKeySource {
361    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
362        let s = String::deserialize(deserializer)?;
363        Ok(Self::from(s.as_str()))
364    }
365}
366
367/// Output formatting style for the session.
368#[derive(Debug, Clone, PartialEq, Eq, Hash)]
369pub enum OutputStyle {
370    /// Default output style.
371    Default,
372    /// A style not yet known to this version of the crate.
373    Unknown(String),
374}
375
376impl OutputStyle {
377    pub fn as_str(&self) -> &str {
378        match self {
379            Self::Default => "default",
380            Self::Unknown(s) => s.as_str(),
381        }
382    }
383}
384
385impl fmt::Display for OutputStyle {
386    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
387        f.write_str(self.as_str())
388    }
389}
390
391impl From<&str> for OutputStyle {
392    fn from(s: &str) -> Self {
393        match s {
394            "default" => Self::Default,
395            other => Self::Unknown(other.to_string()),
396        }
397    }
398}
399
400impl Serialize for OutputStyle {
401    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
402        serializer.serialize_str(self.as_str())
403    }
404}
405
406impl<'de> Deserialize<'de> for OutputStyle {
407    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
408        let s = String::deserialize(deserializer)?;
409        Ok(Self::from(s.as_str()))
410    }
411}
412
413/// Permission mode reported in init messages.
414#[derive(Debug, Clone, PartialEq, Eq, Hash)]
415pub enum InitPermissionMode {
416    /// Default permission mode.
417    Default,
418    AcceptEdits,
419    BypassPermissions,
420    Plan,
421    DontAsk,
422    Auto,
423    /// A mode not yet known to this version of the crate.
424    Unknown(String),
425}
426
427impl InitPermissionMode {
428    pub fn as_str(&self) -> &str {
429        match self {
430            Self::Default => "default",
431            Self::AcceptEdits => "acceptEdits",
432            Self::BypassPermissions => "bypassPermissions",
433            Self::Plan => "plan",
434            Self::DontAsk => "dontAsk",
435            Self::Auto => "auto",
436            Self::Unknown(s) => s.as_str(),
437        }
438    }
439}
440
441impl fmt::Display for InitPermissionMode {
442    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
443        f.write_str(self.as_str())
444    }
445}
446
447impl From<&str> for InitPermissionMode {
448    fn from(s: &str) -> Self {
449        match s {
450            "default" => Self::Default,
451            "acceptEdits" => Self::AcceptEdits,
452            "bypassPermissions" => Self::BypassPermissions,
453            "plan" => Self::Plan,
454            "dontAsk" => Self::DontAsk,
455            "auto" => Self::Auto,
456            other => Self::Unknown(other.to_string()),
457        }
458    }
459}
460
461impl Serialize for InitPermissionMode {
462    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
463        serializer.serialize_str(self.as_str())
464    }
465}
466
467impl<'de> Deserialize<'de> for InitPermissionMode {
468    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
469        let s = String::deserialize(deserializer)?;
470        Ok(Self::from(s.as_str()))
471    }
472}
473
474/// Status of an ongoing operation (e.g., context compaction).
475#[derive(Debug, Clone, PartialEq, Eq, Hash)]
476pub enum StatusMessageStatus {
477    /// Context compaction is in progress.
478    Compacting,
479    /// The CLI is issuing a request.
480    Requesting,
481    /// A status not yet known to this version of the crate.
482    Unknown(String),
483}
484
485impl StatusMessageStatus {
486    pub fn as_str(&self) -> &str {
487        match self {
488            Self::Compacting => "compacting",
489            Self::Requesting => "requesting",
490            Self::Unknown(s) => s.as_str(),
491        }
492    }
493}
494
495impl fmt::Display for StatusMessageStatus {
496    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
497        f.write_str(self.as_str())
498    }
499}
500
501impl From<&str> for StatusMessageStatus {
502    fn from(s: &str) -> Self {
503        match s {
504            "compacting" => Self::Compacting,
505            "requesting" => Self::Requesting,
506            other => Self::Unknown(other.to_string()),
507        }
508    }
509}
510
511impl Serialize for StatusMessageStatus {
512    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
513        serializer.serialize_str(self.as_str())
514    }
515}
516
517impl<'de> Deserialize<'de> for StatusMessageStatus {
518    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
519        let s = String::deserialize(deserializer)?;
520        Ok(Self::from(s.as_str()))
521    }
522}
523
524/// Serialize an optional UUID as a string
525pub(crate) fn serialize_optional_uuid<S>(
526    uuid: &Option<Uuid>,
527    serializer: S,
528) -> Result<S::Ok, S::Error>
529where
530    S: Serializer,
531{
532    match uuid {
533        Some(id) => serializer.serialize_str(&id.to_string()),
534        None => serializer.serialize_none(),
535    }
536}
537
538/// Deserialize an optional UUID from a string
539pub(crate) fn deserialize_optional_uuid<'de, D>(deserializer: D) -> Result<Option<Uuid>, D::Error>
540where
541    D: Deserializer<'de>,
542{
543    let opt_str: Option<String> = Option::deserialize(deserializer)?;
544    match opt_str {
545        Some(s) => Uuid::parse_str(&s)
546            .map(Some)
547            .map_err(serde::de::Error::custom),
548        None => Ok(None),
549    }
550}
551
552/// Message provenance. The `kind` field is the stable discriminator; variant
553/// specific fields are preserved in `extra` for forward-compatible access.
554#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
555pub struct MessageOrigin {
556    pub kind: String,
557    #[serde(flatten)]
558    pub extra: serde_json::Map<String, Value>,
559}
560
561/// Metadata attached when user-visible transcript content summarizes prior messages.
562#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
563pub struct SummarizeMetadata {
564    pub messages_summarized: u64,
565    #[serde(default, skip_serializing_if = "Option::is_none")]
566    pub user_context: Option<String>,
567    #[serde(default, skip_serializing_if = "Option::is_none")]
568    pub direction: Option<String>,
569}
570
571/// MCP metadata passed through on user-message wrappers.
572#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
573pub struct McpMeta {
574    #[serde(default, skip_serializing_if = "Option::is_none", rename = "_meta")]
575    pub meta: Option<Value>,
576    #[serde(default, skip_serializing_if = "Option::is_none")]
577    pub structured_content: Option<Value>,
578    /// The `resource_link` content blocks the MCP tool returned, collected
579    /// from the raw result before the CLI rewrites each into the
580    /// `[Resource link: NAME] URI` text line the model reads. At most 50
581    /// links and 64 KiB serialized; absent when the result had none.
582    #[serde(default, skip_serializing_if = "Vec::is_empty")]
583    pub resource_links: Vec<ResourceLink>,
584}
585
586/// A file an MCP tool returned by reference — a `resource_link` content block
587/// as carried on [`McpMeta::resource_links`] and
588/// [`TaskNotificationMessage::resource_links`] (CLI 2.1.259+).
589#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
590pub struct ResourceLink {
591    pub uri: String,
592    pub name: String,
593    #[serde(default, skip_serializing_if = "Option::is_none")]
594    pub title: Option<String>,
595    #[serde(default, skip_serializing_if = "Option::is_none")]
596    pub description: Option<String>,
597    #[serde(default, skip_serializing_if = "Option::is_none", rename = "mimeType")]
598    pub mime_type: Option<String>,
599    #[serde(default, skip_serializing_if = "Option::is_none")]
600    pub size: Option<u64>,
601    #[serde(default, skip_serializing_if = "Option::is_none")]
602    pub annotations: Option<Value>,
603}
604
605/// Display metadata for a `tool_result` block carried on the user wrapper.
606#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
607pub struct ToolResultMeta {
608    /// The `tool_use_id` of the matching `tool_result` block.
609    pub id: String,
610    /// Harness-stamped reason an `is_error: true` result did not carry the
611    /// tool's own execution output (`user-rejected`, `permission-rule`,
612    /// `automode-*`, `interrupted`, `cancelled`). Open set — treat
613    /// unrecognized values as valid reasons; absent means the tool ran to
614    /// completion.
615    pub non_execution_kind: String,
616    /// The deny comment a human typed at a permission prompt, when present.
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub user_feedback: Option<String>,
619}
620
621/// User message
622#[derive(Debug, Clone, Serialize, Deserialize)]
623pub struct UserMessage {
624    pub message: MessageContent,
625    #[serde(skip_serializing_if = "Option::is_none", alias = "sessionId")]
626    #[serde(
627        serialize_with = "serialize_optional_uuid",
628        deserialize_with = "deserialize_optional_uuid"
629    )]
630    pub session_id: Option<Uuid>,
631    /// Parent tool use ID for nested agent messages
632    #[serde(skip_serializing_if = "Option::is_none")]
633    pub parent_tool_use_id: Option<String>,
634    /// Message-level unique identifier
635    #[serde(skip_serializing_if = "Option::is_none")]
636    pub uuid: Option<String>,
637    /// CLI-emitted ISO-8601 timestamp for the message (present on echoed tool results).
638    #[serde(skip_serializing_if = "Option::is_none")]
639    pub timestamp: Option<String>,
640    /// Structured tool result data echoed by the CLI alongside the `tool_result`
641    /// content block. The shape depends on which tool produced it (e.g. for
642    /// `AskUserQuestion` it is `{ questions, answers }`; for `Bash` it is
643    /// `{ stdout, stderr, exit_code, ... }`). Stored as raw JSON to preserve
644    /// wire fidelity; use [`UserMessage::tool_use_result_as`] to parse into a
645    /// typed shape when you know which tool was invoked.
646    #[serde(skip_serializing_if = "Option::is_none")]
647    pub tool_use_result: Option<serde_json::Value>,
648    /// Subagent type, when this user message is the prompt echoed into a
649    /// `local_agent` subagent (e.g. `general-purpose`).
650    #[serde(skip_serializing_if = "Option::is_none")]
651    pub subagent_type: Option<String>,
652    /// Short description of the subagent task, present alongside `subagent_type`.
653    #[serde(skip_serializing_if = "Option::is_none")]
654    pub task_description: Option<String>,
655    #[serde(skip_serializing_if = "Option::is_none")]
656    pub origin: Option<MessageOrigin>,
657    #[serde(skip_serializing_if = "Option::is_none")]
658    pub priority: Option<String>,
659    #[serde(skip_serializing_if = "Option::is_none", rename = "isSynthetic")]
660    pub is_synthetic: Option<bool>,
661    #[serde(skip_serializing_if = "Option::is_none", rename = "shouldQuery")]
662    pub should_query: Option<bool>,
663    #[serde(default, skip_serializing_if = "Option::is_none")]
664    pub is_meta: Option<bool>,
665    #[serde(default, skip_serializing_if = "Option::is_none")]
666    pub is_visible_in_transcript_only: Option<bool>,
667    #[serde(default, skip_serializing_if = "Option::is_none")]
668    pub is_virtual: Option<bool>,
669    #[serde(default, skip_serializing_if = "Option::is_none")]
670    pub is_compact_summary: Option<bool>,
671    #[serde(skip_serializing_if = "Option::is_none")]
672    pub summarize_metadata: Option<SummarizeMetadata>,
673    #[serde(skip_serializing_if = "Option::is_none")]
674    pub mcp_meta: Option<McpMeta>,
675    /// Display metadata for this message's `tool_result` blocks, keyed by
676    /// `tool_use_id`.
677    #[serde(skip_serializing_if = "Option::is_none")]
678    pub tool_result_meta: Option<Vec<ToolResultMeta>>,
679    #[serde(skip_serializing_if = "Option::is_none")]
680    pub source_tool_use_id: Option<String>,
681    #[serde(skip_serializing_if = "Option::is_none")]
682    pub source_tool_assistant_uuid: Option<String>,
683    #[serde(skip_serializing_if = "Option::is_none")]
684    pub image_paste_ids: Option<Vec<u64>>,
685    #[serde(skip_serializing_if = "Option::is_none")]
686    pub client_platform: Option<String>,
687    #[serde(skip_serializing_if = "Option::is_none")]
688    pub inbound_origin: Option<String>,
689    #[serde(skip_serializing_if = "Option::is_none", rename = "isReplay")]
690    pub is_replay: Option<bool>,
691    #[serde(skip_serializing_if = "Option::is_none")]
692    pub file_attachments: Option<Vec<Value>>,
693    /// Desktop host only: the host's own seeded summon (CLI 2.1.239+).
694    #[serde(default, skip_serializing_if = "Option::is_none")]
695    pub seeded_summon: Option<bool>,
696    /// True when the client composed this turn from content the user did not
697    /// type; its text is delivered as written (CLI 2.1.259+).
698    #[serde(default, skip_serializing_if = "Option::is_none")]
699    pub client_composed: Option<bool>,
700}
701
702impl UserMessage {
703    /// Parse the `tool_use_result` field into a caller-specified type.
704    ///
705    /// Returns `None` if `tool_use_result` is absent, otherwise returns the
706    /// deserialization result. The caller must know which tool produced the
707    /// result and supply a matching type — e.g. for `AskUserQuestion` use
708    /// [`AskUserQuestionInput`](crate::AskUserQuestionInput), whose
709    /// `questions` + `answers` fields match the wire result shape.
710    pub fn tool_use_result_as<T: serde::de::DeserializeOwned>(
711        &self,
712    ) -> Option<Result<T, serde_json::Error>> {
713        self.tool_use_result
714            .as_ref()
715            .map(|v| serde_json::from_value(v.clone()))
716    }
717
718    /// Parse the `tool_use_result` as a subagent (`Task`) run result.
719    ///
720    /// When this user message echoes the result of a `Task` tool call, the CLI
721    /// attaches a structured `tool_use_result` carrying the subagent's token,
722    /// timing, and tool-use accounting. Returns `None` when the field is absent
723    /// or does not parse as a [`SubagentResult`].
724    ///
725    /// Summing [`SubagentResult::total_tokens`] across every `Task` result in a
726    /// session yields the subagent token rollup the CLI renders as
727    /// `subagent_tokens` in its terminal `<usage>` block.
728    pub fn subagent_result(&self) -> Option<SubagentResult> {
729        self.tool_use_result
730            .as_ref()
731            .and_then(|v| serde_json::from_value(v.clone()).ok())
732    }
733}
734
735/// Token, timing, and tool-use accounting for a completed subagent (`Task`) run.
736///
737/// The Claude CLI echoes this object in the `tool_use_result` of a `Task` tool's
738/// result message. It is the typed source of truth for subagent token
739/// attribution: the per-run [`total_tokens`](Self::total_tokens),
740/// [`total_duration_ms`](Self::total_duration_ms), and
741/// [`total_tool_use_count`](Self::total_tool_use_count) correspond to the
742/// `subagent_tokens` / `duration_ms` / `tool_uses` line items the CLI renders in
743/// its human-readable `<usage>` block, and [`usage`](Self::usage) carries the
744/// full per-model token breakdown for the run.
745#[derive(Debug, Clone, Serialize, Deserialize)]
746pub struct SubagentResult {
747    /// Completion status of the subagent run (e.g. `"completed"`).
748    #[serde(skip_serializing_if = "Option::is_none")]
749    pub status: Option<String>,
750    /// The prompt the subagent was launched with.
751    #[serde(skip_serializing_if = "Option::is_none")]
752    pub prompt: Option<String>,
753    /// Stable identifier of the spawned subagent.
754    #[serde(rename = "agentId", skip_serializing_if = "Option::is_none")]
755    pub agent_id: Option<String>,
756    /// Subagent type that ran (e.g. `general-purpose`, `Explore`).
757    #[serde(rename = "agentType", skip_serializing_if = "Option::is_none")]
758    pub agent_type: Option<String>,
759    /// Final content blocks the subagent returned.
760    #[serde(
761        default,
762        deserialize_with = "deserialize_content_blocks",
763        skip_serializing_if = "Vec::is_empty"
764    )]
765    pub content: Vec<ContentBlock>,
766    /// Model the subagent actually resolved to (e.g. `claude-sonnet-4-6`).
767    #[serde(rename = "resolvedModel", skip_serializing_if = "Option::is_none")]
768    pub resolved_model: Option<String>,
769    /// Wall-clock duration of the subagent run, in milliseconds.
770    #[serde(rename = "totalDurationMs", skip_serializing_if = "Option::is_none")]
771    pub total_duration_ms: Option<u64>,
772    /// Total tokens consumed by the subagent — the `subagent_tokens` rollup line.
773    #[serde(rename = "totalTokens", skip_serializing_if = "Option::is_none")]
774    pub total_tokens: Option<u64>,
775    /// Number of tool invocations the subagent made.
776    #[serde(rename = "totalToolUseCount", skip_serializing_if = "Option::is_none")]
777    pub total_tool_use_count: Option<u64>,
778    /// Detailed token / cache usage for the subagent run.
779    #[serde(skip_serializing_if = "Option::is_none")]
780    pub usage: Option<super::result::UsageInfo>,
781    /// Per-category tool-use counts, present for some agent types (e.g. `Explore`).
782    #[serde(rename = "toolStats", skip_serializing_if = "Option::is_none")]
783    pub tool_stats: Option<SubagentToolStats>,
784}
785
786/// Per-category tool-use counts for a subagent run, from `tool_use_result.toolStats`.
787///
788/// The `extra` field captures any counters the CLI adds that aren't modeled here,
789/// so new wire fields deserialize without error.
790#[derive(Debug, Clone, Default, Serialize, Deserialize)]
791#[serde(rename_all = "camelCase")]
792pub struct SubagentToolStats {
793    #[serde(default)]
794    pub read_count: u64,
795    #[serde(default)]
796    pub search_count: u64,
797    #[serde(default)]
798    pub bash_count: u64,
799    #[serde(default)]
800    pub edit_file_count: u64,
801    #[serde(default)]
802    pub lines_added: u64,
803    #[serde(default)]
804    pub lines_removed: u64,
805    #[serde(default)]
806    pub other_tool_count: u64,
807    #[serde(flatten)]
808    pub extra: serde_json::Map<String, Value>,
809}
810
811/// Session-level subagent token rollup — the `<subagent_tokens>` /
812/// `<agent_count>` line items the Claude CLI renders in its terminal
813/// `<usage>` block.
814///
815/// The `stream-json` protocol does **not** carry this rollup on the `result`
816/// frame's `usage` (confirmed against the CLI binary — the terminal renderer
817/// computes it from `Task` tool results). Consumers that need it must
818/// accumulate it the same way: feed every session message through
819/// [`observe`](Self::observe) and read the totals at any point.
820///
821/// A `Task` result observed twice under the same `agentId` (e.g. a replayed
822/// frame on resume) is counted once. Results with no `agentId` are counted
823/// every time they are observed.
824///
825/// # Example
826///
827/// ```
828/// use claude_codes::{ClaudeOutput, SubagentUsageRollup};
829///
830/// let mut rollup = SubagentUsageRollup::default();
831/// let json = r#"{"type":"user","message":{"role":"user","content":[]},"session_id":"7fbc568e-2bd6-45aa-b217-a1cf80004ba1","tool_use_result":{"status":"completed","agentId":"ab52f22445470d454","totalDurationMs":1853,"totalTokens":10201,"totalToolUseCount":0}}"#;
832/// let output: ClaudeOutput = serde_json::from_str(json).unwrap();
833/// rollup.observe(&output);
834/// assert_eq!(rollup.subagent_tokens, 10201);
835/// assert_eq!(rollup.agent_count, 1);
836/// ```
837#[derive(Debug, Clone, Default, PartialEq, Eq)]
838pub struct SubagentUsageRollup {
839    /// Total tokens consumed by subagents — sum of
840    /// [`SubagentResult::total_tokens`] over every observed `Task` result.
841    pub subagent_tokens: u64,
842    /// Number of subagent runs observed (`<agent_count>`).
843    pub agent_count: u32,
844    /// Total subagent tool invocations — sum of `total_tool_use_count`.
845    pub tool_uses: u64,
846    /// Total subagent wall-clock milliseconds — sum of `total_duration_ms`.
847    pub duration_ms: u64,
848    seen_agent_ids: std::collections::BTreeSet<String>,
849}
850
851impl SubagentUsageRollup {
852    /// Accumulate `output` into the rollup if it is a `Task` tool result.
853    ///
854    /// Returns `true` when the message contributed to the totals. Non-user
855    /// messages, user messages without a `tool_use_result`, results from
856    /// other tools, and duplicate `agentId`s are all ignored.
857    pub fn observe(&mut self, output: &ClaudeOutput) -> bool {
858        match output {
859            ClaudeOutput::User(user) => self.observe_user(user),
860            _ => false,
861        }
862    }
863
864    /// Accumulate a user message's `Task` tool result, if it carries one.
865    ///
866    /// Every [`SubagentResult`] field is optional, so any JSON object in
867    /// `tool_use_result` parses as one (e.g. a `Bash` or `ToolSearch`
868    /// result). Only results carrying an `agentId` or a `totalTokens`
869    /// line item are treated as genuine `Task` results.
870    pub fn observe_user(&mut self, user: &UserMessage) -> bool {
871        let Some(result) = user.subagent_result() else {
872            return false;
873        };
874        if result.total_tokens.is_none() && result.agent_id.is_none() {
875            return false;
876        }
877        if let Some(agent_id) = &result.agent_id {
878            if !self.seen_agent_ids.insert(agent_id.clone()) {
879                return false;
880            }
881        }
882        self.agent_count += 1;
883        self.subagent_tokens += result.total_tokens.unwrap_or(0);
884        self.tool_uses += result.total_tool_use_count.unwrap_or(0);
885        self.duration_ms += result.total_duration_ms.unwrap_or(0);
886        true
887    }
888}
889
890/// Message content with role
891#[derive(Debug, Clone, Serialize, Deserialize)]
892pub struct MessageContent {
893    pub role: MessageRole,
894    #[serde(deserialize_with = "deserialize_content_blocks")]
895    pub content: Vec<ContentBlock>,
896}
897
898/// System message with metadata
899#[derive(Debug, Clone, Serialize, Deserialize)]
900pub struct SystemMessage {
901    pub subtype: SystemSubtype,
902    #[serde(flatten)]
903    pub data: Value, // Captures all other fields
904}
905
906impl SystemMessage {
907    /// Check if this is an init message
908    pub fn is_init(&self) -> bool {
909        self.subtype == SystemSubtype::Init
910    }
911
912    /// Check if this is a status message
913    pub fn is_status(&self) -> bool {
914        self.subtype == SystemSubtype::Status
915    }
916
917    /// Check if this is a compact_boundary message
918    pub fn is_compact_boundary(&self) -> bool {
919        self.subtype == SystemSubtype::CompactBoundary
920    }
921
922    /// Try to parse as an init message
923    pub fn as_init(&self) -> Option<InitMessage> {
924        if self.subtype != SystemSubtype::Init {
925            return None;
926        }
927        serde_json::from_value(self.data.clone()).ok()
928    }
929
930    /// Try to parse as a status message
931    pub fn as_status(&self) -> Option<StatusMessage> {
932        if self.subtype != SystemSubtype::Status {
933            return None;
934        }
935        serde_json::from_value(self.data.clone()).ok()
936    }
937
938    /// Try to parse as a compact_boundary message
939    pub fn as_compact_boundary(&self) -> Option<CompactBoundaryMessage> {
940        if self.subtype != SystemSubtype::CompactBoundary {
941            return None;
942        }
943        serde_json::from_value(self.data.clone()).ok()
944    }
945
946    /// Check if this is a task_started message
947    pub fn is_task_started(&self) -> bool {
948        self.subtype == SystemSubtype::TaskStarted
949    }
950
951    /// Check if this is a task_progress message
952    pub fn is_task_progress(&self) -> bool {
953        self.subtype == SystemSubtype::TaskProgress
954    }
955
956    /// Check if this is a task_notification message
957    pub fn is_task_notification(&self) -> bool {
958        self.subtype == SystemSubtype::TaskNotification
959    }
960
961    /// Try to parse as a task_started message
962    pub fn as_task_started(&self) -> Option<TaskStartedMessage> {
963        if self.subtype != SystemSubtype::TaskStarted {
964            return None;
965        }
966        serde_json::from_value(self.data.clone()).ok()
967    }
968
969    /// Try to parse as a task_progress message
970    pub fn as_task_progress(&self) -> Option<TaskProgressMessage> {
971        if self.subtype != SystemSubtype::TaskProgress {
972            return None;
973        }
974        serde_json::from_value(self.data.clone()).ok()
975    }
976
977    /// Try to parse as a task_notification message
978    pub fn as_task_notification(&self) -> Option<TaskNotificationMessage> {
979        if self.subtype != SystemSubtype::TaskNotification {
980            return None;
981        }
982        serde_json::from_value(self.data.clone()).ok()
983    }
984
985    /// Check if this is a task_updated message
986    pub fn is_task_updated(&self) -> bool {
987        self.subtype == SystemSubtype::TaskUpdated
988    }
989
990    /// Try to parse as a task_updated message
991    pub fn as_task_updated(&self) -> Option<TaskUpdatedMessage> {
992        if self.subtype != SystemSubtype::TaskUpdated {
993            return None;
994        }
995        serde_json::from_value(self.data.clone()).ok()
996    }
997
998    /// Check if this is a thinking_tokens message
999    pub fn is_thinking_tokens(&self) -> bool {
1000        self.subtype == SystemSubtype::ThinkingTokens
1001    }
1002
1003    /// Try to parse as a thinking_tokens message
1004    pub fn as_thinking_tokens(&self) -> Option<ThinkingTokensMessage> {
1005        if self.subtype != SystemSubtype::ThinkingTokens {
1006            return None;
1007        }
1008        serde_json::from_value(self.data.clone()).ok()
1009    }
1010
1011    /// Check if this is a code_change_published message
1012    pub fn is_code_change_published(&self) -> bool {
1013        self.subtype == SystemSubtype::CodeChangePublished
1014    }
1015
1016    /// Try to parse as a code_change_published message
1017    pub fn as_code_change_published(&self) -> Option<CodeChangePublishedMessage> {
1018        if self.subtype != SystemSubtype::CodeChangePublished {
1019            return None;
1020        }
1021        serde_json::from_value(self.data.clone()).ok()
1022    }
1023
1024    /// Check if this is a vcs_state_changed message
1025    pub fn is_vcs_state_changed(&self) -> bool {
1026        self.subtype == SystemSubtype::VcsStateChanged
1027    }
1028
1029    /// Try to parse as a vcs_state_changed message
1030    pub fn as_vcs_state_changed(&self) -> Option<VcsStateChangedMessage> {
1031        if self.subtype != SystemSubtype::VcsStateChanged {
1032            return None;
1033        }
1034        serde_json::from_value(self.data.clone()).ok()
1035    }
1036
1037    /// Check if this is a feedback_draft_queued message.
1038    pub fn is_feedback_draft_queued(&self) -> bool {
1039        self.subtype == SystemSubtype::FeedbackDraftQueued
1040    }
1041
1042    /// Try to parse as a feedback_draft_queued message.
1043    pub fn as_feedback_draft_queued(&self) -> Option<FeedbackDraftQueuedMessage> {
1044        if self.subtype != SystemSubtype::FeedbackDraftQueued {
1045            return None;
1046        }
1047        serde_json::from_value(self.data.clone()).ok()
1048    }
1049
1050    /// Check if this is a cloud_session_delta message.
1051    pub fn is_cloud_session_delta(&self) -> bool {
1052        self.subtype == SystemSubtype::CloudSessionDelta
1053    }
1054
1055    /// Try to parse as a cloud_session_delta message.
1056    pub fn as_cloud_session_delta(&self) -> Option<CloudSessionDeltaMessage> {
1057        if self.subtype != SystemSubtype::CloudSessionDelta {
1058            return None;
1059        }
1060        serde_json::from_value(self.data.clone()).ok()
1061    }
1062
1063    /// Parse any typed system subtype known to this crate version.
1064    pub fn as_known_system_event(&self) -> Option<KnownSystemEvent> {
1065        macro_rules! parse {
1066            ($variant:ident, $ty:ty) => {
1067                serde_json::from_value::<$ty>(self.data.clone())
1068                    .ok()
1069                    .map(KnownSystemEvent::$variant)
1070            };
1071        }
1072
1073        match self.subtype {
1074            SystemSubtype::Init => parse!(Init, InitMessage),
1075            SystemSubtype::Status => parse!(Status, StatusMessage),
1076            SystemSubtype::CompactBoundary => parse!(CompactBoundary, CompactBoundaryMessage),
1077            SystemSubtype::ThinkingTokens => parse!(ThinkingTokens, ThinkingTokensMessage),
1078            SystemSubtype::TaskStarted => parse!(TaskStarted, TaskStartedMessage),
1079            SystemSubtype::TaskProgress => parse!(TaskProgress, TaskProgressMessage),
1080            SystemSubtype::TaskUpdated => parse!(TaskUpdated, TaskUpdatedMessage),
1081            SystemSubtype::TaskNotification => parse!(TaskNotification, TaskNotificationMessage),
1082            SystemSubtype::ApiRetry => parse!(ApiRetry, ApiRetryMessage),
1083            SystemSubtype::ControlRequestProgress => {
1084                parse!(ControlRequestProgress, ControlRequestProgressMessage)
1085            }
1086            SystemSubtype::ModelRefusalFallback => {
1087                parse!(ModelRefusalFallback, ModelRefusalFallbackMessage)
1088            }
1089            SystemSubtype::ModelRefusalNoFallback => {
1090                parse!(ModelRefusalNoFallback, ModelRefusalNoFallbackMessage)
1091            }
1092            SystemSubtype::LocalCommandOutput => {
1093                parse!(LocalCommandOutput, LocalCommandOutputMessage)
1094            }
1095            SystemSubtype::HookStarted => parse!(HookStarted, HookStartedMessage),
1096            SystemSubtype::HookProgress => parse!(HookProgress, HookProgressMessage),
1097            SystemSubtype::HookResponse => parse!(HookResponse, HookResponseMessage),
1098            SystemSubtype::PluginInstall => parse!(PluginInstall, PluginInstallMessage),
1099            SystemSubtype::BackgroundTasksChanged => {
1100                parse!(BackgroundTasksChanged, BackgroundTasksChangedMessage)
1101            }
1102            SystemSubtype::SessionStateChanged => {
1103                parse!(SessionStateChanged, SessionStateChangedMessage)
1104            }
1105            SystemSubtype::WorkerShuttingDown => {
1106                parse!(WorkerShuttingDown, WorkerShuttingDownMessage)
1107            }
1108            SystemSubtype::CommandsChanged => parse!(CommandsChanged, CommandsChangedMessage),
1109            SystemSubtype::Notification => parse!(Notification, NotificationMessage),
1110            SystemSubtype::FilesPersisted => parse!(FilesPersisted, FilesPersistedMessage),
1111            SystemSubtype::MemoryRecall => parse!(MemoryRecall, MemoryRecallMessage),
1112            SystemSubtype::ElicitationComplete => {
1113                parse!(ElicitationComplete, ElicitationCompleteMessage)
1114            }
1115            SystemSubtype::PermissionDenied => parse!(PermissionDenied, PermissionDeniedMessage),
1116            SystemSubtype::MirrorError => parse!(MirrorError, MirrorErrorMessage),
1117            SystemSubtype::Informational => parse!(Informational, InformationalMessage),
1118            SystemSubtype::CodeChangePublished => {
1119                parse!(CodeChangePublished, CodeChangePublishedMessage)
1120            }
1121            SystemSubtype::VcsStateChanged => parse!(VcsStateChanged, VcsStateChangedMessage),
1122            SystemSubtype::FeedbackDraftQueued => {
1123                parse!(FeedbackDraftQueued, FeedbackDraftQueuedMessage)
1124            }
1125            SystemSubtype::CloudSessionDelta => {
1126                parse!(CloudSessionDelta, CloudSessionDeltaMessage)
1127            }
1128            SystemSubtype::Unknown(_) => None,
1129        }
1130    }
1131
1132    /// Re-serialize this system message's payload through the typed view that
1133    /// matches its `subtype`, returning the result as JSON.
1134    ///
1135    /// Used by the wrapping audit ([`crate::io::audit_frame`]) to verify that a
1136    /// subtype's dedicated struct captures every wire field: the audit compares
1137    /// this against the raw [`SystemMessage::data`]. Returns `None` for subtypes
1138    /// this crate version has no dedicated struct for (including
1139    /// [`SystemSubtype::Unknown`]) — those are reported as not fully wrapped.
1140    pub fn typed_value(&self) -> Option<Value> {
1141        fn reserialize<T: Serialize>(parsed: Option<T>) -> Option<Value> {
1142            parsed.and_then(|v| serde_json::to_value(v).ok())
1143        }
1144        match self.subtype {
1145            SystemSubtype::Init => reserialize(self.as_init()),
1146            SystemSubtype::Status => reserialize(self.as_status()),
1147            SystemSubtype::CompactBoundary => reserialize(self.as_compact_boundary()),
1148            SystemSubtype::ThinkingTokens => reserialize(self.as_thinking_tokens()),
1149            SystemSubtype::TaskStarted => reserialize(self.as_task_started()),
1150            SystemSubtype::TaskProgress => reserialize(self.as_task_progress()),
1151            SystemSubtype::TaskUpdated => reserialize(self.as_task_updated()),
1152            SystemSubtype::TaskNotification => reserialize(self.as_task_notification()),
1153            SystemSubtype::ApiRetry => reserialize(parse_system::<ApiRetryMessage>(self)),
1154            SystemSubtype::ControlRequestProgress => {
1155                reserialize(parse_system::<ControlRequestProgressMessage>(self))
1156            }
1157            SystemSubtype::ModelRefusalFallback => {
1158                reserialize(parse_system::<ModelRefusalFallbackMessage>(self))
1159            }
1160            SystemSubtype::ModelRefusalNoFallback => {
1161                reserialize(parse_system::<ModelRefusalNoFallbackMessage>(self))
1162            }
1163            SystemSubtype::LocalCommandOutput => {
1164                reserialize(parse_system::<LocalCommandOutputMessage>(self))
1165            }
1166            SystemSubtype::HookStarted => reserialize(parse_system::<HookStartedMessage>(self)),
1167            SystemSubtype::HookProgress => reserialize(parse_system::<HookProgressMessage>(self)),
1168            SystemSubtype::HookResponse => reserialize(parse_system::<HookResponseMessage>(self)),
1169            SystemSubtype::PluginInstall => reserialize(parse_system::<PluginInstallMessage>(self)),
1170            SystemSubtype::BackgroundTasksChanged => {
1171                reserialize(parse_system::<BackgroundTasksChangedMessage>(self))
1172            }
1173            SystemSubtype::SessionStateChanged => {
1174                reserialize(parse_system::<SessionStateChangedMessage>(self))
1175            }
1176            SystemSubtype::WorkerShuttingDown => {
1177                reserialize(parse_system::<WorkerShuttingDownMessage>(self))
1178            }
1179            SystemSubtype::CommandsChanged => {
1180                reserialize(parse_system::<CommandsChangedMessage>(self))
1181            }
1182            SystemSubtype::Notification => reserialize(parse_system::<NotificationMessage>(self)),
1183            SystemSubtype::FilesPersisted => {
1184                reserialize(parse_system::<FilesPersistedMessage>(self))
1185            }
1186            SystemSubtype::MemoryRecall => reserialize(parse_system::<MemoryRecallMessage>(self)),
1187            SystemSubtype::ElicitationComplete => {
1188                reserialize(parse_system::<ElicitationCompleteMessage>(self))
1189            }
1190            SystemSubtype::PermissionDenied => {
1191                reserialize(parse_system::<PermissionDeniedMessage>(self))
1192            }
1193            SystemSubtype::MirrorError => reserialize(parse_system::<MirrorErrorMessage>(self)),
1194            SystemSubtype::Informational => reserialize(parse_system::<InformationalMessage>(self)),
1195            SystemSubtype::CodeChangePublished => {
1196                reserialize(parse_system::<CodeChangePublishedMessage>(self))
1197            }
1198            SystemSubtype::VcsStateChanged => {
1199                reserialize(parse_system::<VcsStateChangedMessage>(self))
1200            }
1201            SystemSubtype::FeedbackDraftQueued => {
1202                reserialize(parse_system::<FeedbackDraftQueuedMessage>(self))
1203            }
1204            SystemSubtype::CloudSessionDelta => {
1205                reserialize(parse_system::<CloudSessionDeltaMessage>(self))
1206            }
1207            SystemSubtype::Unknown(_) => None,
1208        }
1209    }
1210}
1211
1212fn parse_system<T: serde::de::DeserializeOwned>(message: &SystemMessage) -> Option<T> {
1213    serde_json::from_value(message.data.clone()).ok()
1214}
1215
1216/// Owned typed view over any known system message subtype.
1217// `InitMessage` outgrew clippy's variant-size threshold when CLI 2.1.232
1218// added fields. This enum is a transient per-parse classification (never
1219// stored in bulk), so boxing would break every match site for no retained-
1220// memory win.
1221#[allow(clippy::large_enum_variant)]
1222#[derive(Debug, Clone, Serialize, Deserialize)]
1223pub enum KnownSystemEvent {
1224    Init(InitMessage),
1225    Status(StatusMessage),
1226    CompactBoundary(CompactBoundaryMessage),
1227    ThinkingTokens(ThinkingTokensMessage),
1228    TaskStarted(TaskStartedMessage),
1229    TaskProgress(TaskProgressMessage),
1230    TaskUpdated(TaskUpdatedMessage),
1231    TaskNotification(TaskNotificationMessage),
1232    ApiRetry(ApiRetryMessage),
1233    ControlRequestProgress(ControlRequestProgressMessage),
1234    ModelRefusalFallback(ModelRefusalFallbackMessage),
1235    ModelRefusalNoFallback(ModelRefusalNoFallbackMessage),
1236    LocalCommandOutput(LocalCommandOutputMessage),
1237    HookStarted(HookStartedMessage),
1238    HookProgress(HookProgressMessage),
1239    HookResponse(HookResponseMessage),
1240    PluginInstall(PluginInstallMessage),
1241    BackgroundTasksChanged(BackgroundTasksChangedMessage),
1242    SessionStateChanged(SessionStateChangedMessage),
1243    WorkerShuttingDown(WorkerShuttingDownMessage),
1244    CommandsChanged(CommandsChangedMessage),
1245    Notification(NotificationMessage),
1246    FilesPersisted(FilesPersistedMessage),
1247    MemoryRecall(MemoryRecallMessage),
1248    ElicitationComplete(ElicitationCompleteMessage),
1249    PermissionDenied(PermissionDeniedMessage),
1250    MirrorError(MirrorErrorMessage),
1251    Informational(InformationalMessage),
1252    CodeChangePublished(CodeChangePublishedMessage),
1253    VcsStateChanged(VcsStateChangedMessage),
1254    FeedbackDraftQueued(FeedbackDraftQueuedMessage),
1255    CloudSessionDelta(CloudSessionDeltaMessage),
1256}
1257
1258#[derive(Debug, Clone, Serialize, Deserialize)]
1259pub struct ApiRetryMessage {
1260    pub attempt: u64,
1261    pub max_retries: u64,
1262    pub retry_delay_ms: u64,
1263    pub error_status: Option<u16>,
1264    pub error: String,
1265    /// Present only when the API sent no response headers within the
1266    /// first-byte window (`CLAUDE_STREAM_FIRST_BYTE_TIMEOUT_MS`). For this
1267    /// cause `max_retries` is its own cap (normally one retry), not the
1268    /// session budget (CLI 2.1.261+).
1269    #[serde(default, skip_serializing_if = "Option::is_none")]
1270    pub no_response: Option<ApiRetryNoResponse>,
1271    #[serde(default, skip_serializing_if = "Option::is_none")]
1272    pub uuid: Option<String>,
1273    #[serde(default, skip_serializing_if = "Option::is_none")]
1274    pub session_id: Option<String>,
1275}
1276
1277/// Timing of a first-byte-timeout retry, carried as
1278/// [`ApiRetryMessage::no_response`].
1279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1280pub struct ApiRetryNoResponse {
1281    /// How long the failed attempt waited for response headers.
1282    pub waited_ms: u64,
1283    /// How long the retry will wait for them.
1284    pub retry_wait_ms: u64,
1285}
1286
1287#[derive(Debug, Clone, Serialize, Deserialize)]
1288pub struct ControlRequestProgressMessage {
1289    pub request_id: String,
1290    pub status: String,
1291    #[serde(default, skip_serializing_if = "Option::is_none")]
1292    pub attempt: Option<u64>,
1293    #[serde(default, skip_serializing_if = "Option::is_none")]
1294    pub max_retries: Option<u64>,
1295    #[serde(default, skip_serializing_if = "Option::is_none")]
1296    pub retry_delay_ms: Option<u64>,
1297    #[serde(default, skip_serializing_if = "Option::is_none")]
1298    pub error_status: Option<u16>,
1299    #[serde(default, skip_serializing_if = "Option::is_none")]
1300    pub error: Option<String>,
1301    #[serde(default, skip_serializing_if = "Option::is_none")]
1302    pub uuid: Option<String>,
1303    #[serde(default, skip_serializing_if = "Option::is_none")]
1304    pub session_id: Option<String>,
1305}
1306
1307#[derive(Debug, Clone, Serialize, Deserialize)]
1308pub struct ModelRefusalFallbackMessage {
1309    pub trigger: String,
1310    pub direction: String,
1311    /// `"session"`: the main thread fell back and the session model is
1312    /// swapped. `"local"`: a subagent / side-question (`/btw`) / background
1313    /// fork fell back — only that response came from the fallback model and
1314    /// the session model is unchanged. Absent from CLIs before 2.1.222
1315    /// (treat as `"session"`).
1316    #[serde(default, skip_serializing_if = "Option::is_none")]
1317    pub scope: Option<RefusalFallbackScope>,
1318    pub original_model: String,
1319    pub fallback_model: String,
1320    pub request_id: Option<String>,
1321    #[serde(default, skip_serializing_if = "Option::is_none")]
1322    pub api_refusal_category: Option<String>,
1323    /// Present when any hop of this banner's multi-hop episode was a cyber
1324    /// refusal — not only the origin hop `api_refusal_category` describes.
1325    /// Re-arm evidence for the CLI's cyber-exclusion header on session
1326    /// restore; absent on cyber-free episodes and older CLIs (2.1.239+).
1327    #[serde(default, skip_serializing_if = "Option::is_none")]
1328    pub saw_cyber_refusal: Option<bool>,
1329    #[serde(default, skip_serializing_if = "Option::is_none")]
1330    pub api_refusal_explanation: Option<String>,
1331    #[serde(default, skip_serializing_if = "Option::is_none")]
1332    pub retracted_message_uuids: Option<Vec<String>>,
1333    #[serde(default, skip_serializing_if = "Option::is_none")]
1334    pub refused_user_message_uuid: Option<String>,
1335    pub content: Value,
1336    #[serde(default, skip_serializing_if = "Option::is_none")]
1337    pub uuid: Option<String>,
1338    #[serde(default, skip_serializing_if = "Option::is_none")]
1339    pub session_id: Option<String>,
1340}
1341
1342/// Scope of a refusal-fallback model swap, carried by
1343/// [`ModelRefusalFallbackMessage::scope`]. Open — new scopes may ship on the
1344/// wire ahead of schema updates.
1345#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1346pub enum RefusalFallbackScope {
1347    /// The main thread fell back; the session model is swapped.
1348    Session,
1349    /// A subagent / side-question / background fork fell back; only that
1350    /// response used the fallback model, the session model is unchanged.
1351    Local,
1352    /// A scope not yet known to this version of the crate.
1353    Unknown(String),
1354}
1355
1356impl RefusalFallbackScope {
1357    pub fn as_str(&self) -> &str {
1358        match self {
1359            Self::Session => "session",
1360            Self::Local => "local",
1361            Self::Unknown(s) => s.as_str(),
1362        }
1363    }
1364}
1365
1366impl fmt::Display for RefusalFallbackScope {
1367    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1368        f.write_str(self.as_str())
1369    }
1370}
1371
1372impl From<&str> for RefusalFallbackScope {
1373    fn from(s: &str) -> Self {
1374        match s {
1375            "session" => Self::Session,
1376            "local" => Self::Local,
1377            other => Self::Unknown(other.to_string()),
1378        }
1379    }
1380}
1381
1382impl Serialize for RefusalFallbackScope {
1383    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1384        serializer.serialize_str(self.as_str())
1385    }
1386}
1387
1388impl<'de> Deserialize<'de> for RefusalFallbackScope {
1389    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1390        let s = String::deserialize(deserializer)?;
1391        Ok(Self::from(s.as_str()))
1392    }
1393}
1394
1395#[derive(Debug, Clone, Serialize, Deserialize)]
1396pub struct ModelRefusalNoFallbackMessage {
1397    pub original_model: String,
1398    pub request_id: Option<String>,
1399    #[serde(default, skip_serializing_if = "Option::is_none")]
1400    pub api_refusal_category: Option<String>,
1401    #[serde(default, skip_serializing_if = "Option::is_none")]
1402    pub api_refusal_explanation: Option<String>,
1403    pub content: Value,
1404    #[serde(default, skip_serializing_if = "Option::is_none")]
1405    pub uuid: Option<String>,
1406    #[serde(default, skip_serializing_if = "Option::is_none")]
1407    pub session_id: Option<String>,
1408}
1409
1410#[derive(Debug, Clone, Serialize, Deserialize)]
1411pub struct LocalCommandOutputMessage {
1412    pub content: String,
1413    #[serde(default, skip_serializing_if = "Option::is_none")]
1414    pub uuid: Option<String>,
1415    #[serde(default, skip_serializing_if = "Option::is_none")]
1416    pub session_id: Option<String>,
1417}
1418
1419#[derive(Debug, Clone, Serialize, Deserialize)]
1420pub struct HookStartedMessage {
1421    pub hook_id: String,
1422    pub hook_name: String,
1423    pub hook_event: String,
1424    #[serde(default, skip_serializing_if = "Option::is_none")]
1425    pub uuid: Option<String>,
1426    #[serde(default, skip_serializing_if = "Option::is_none")]
1427    pub session_id: Option<String>,
1428}
1429
1430#[derive(Debug, Clone, Serialize, Deserialize)]
1431pub struct HookProgressMessage {
1432    pub hook_id: String,
1433    pub hook_name: String,
1434    pub hook_event: String,
1435    #[serde(default, skip_serializing_if = "Option::is_none")]
1436    pub stdout: Option<String>,
1437    #[serde(default, skip_serializing_if = "Option::is_none")]
1438    pub stderr: Option<String>,
1439    #[serde(default, skip_serializing_if = "Option::is_none")]
1440    pub output: Option<String>,
1441    #[serde(default, skip_serializing_if = "Option::is_none")]
1442    pub uuid: Option<String>,
1443    #[serde(default, skip_serializing_if = "Option::is_none")]
1444    pub session_id: Option<String>,
1445}
1446
1447#[derive(Debug, Clone, Serialize, Deserialize)]
1448pub struct HookResponseMessage {
1449    pub hook_id: String,
1450    pub hook_name: String,
1451    pub hook_event: String,
1452    #[serde(default, skip_serializing_if = "Option::is_none")]
1453    pub stdout: Option<String>,
1454    #[serde(default, skip_serializing_if = "Option::is_none")]
1455    pub stderr: Option<String>,
1456    #[serde(default, skip_serializing_if = "Option::is_none")]
1457    pub output: Option<String>,
1458    #[serde(default, skip_serializing_if = "Option::is_none")]
1459    pub exit_code: Option<i32>,
1460    pub outcome: String,
1461    #[serde(default, skip_serializing_if = "Option::is_none")]
1462    pub uuid: Option<String>,
1463    #[serde(default, skip_serializing_if = "Option::is_none")]
1464    pub session_id: Option<String>,
1465}
1466
1467#[derive(Debug, Clone, Serialize, Deserialize)]
1468pub struct PluginInstallMessage {
1469    pub status: String,
1470    #[serde(default, skip_serializing_if = "Option::is_none")]
1471    pub name: Option<String>,
1472    #[serde(default, skip_serializing_if = "Option::is_none")]
1473    pub error: Option<String>,
1474    #[serde(default, skip_serializing_if = "Option::is_none")]
1475    pub uuid: Option<String>,
1476    #[serde(default, skip_serializing_if = "Option::is_none")]
1477    pub session_id: Option<String>,
1478}
1479
1480#[derive(Debug, Clone, Serialize, Deserialize)]
1481pub struct BackgroundTasksChangedMessage {
1482    pub tasks: Vec<BackgroundTaskInfo>,
1483    #[serde(default, skip_serializing_if = "Option::is_none")]
1484    pub uuid: Option<String>,
1485    #[serde(default, skip_serializing_if = "Option::is_none")]
1486    pub session_id: Option<String>,
1487}
1488
1489#[derive(Debug, Clone, Serialize, Deserialize)]
1490pub struct BackgroundTaskInfo {
1491    pub task_id: String,
1492    pub task_type: String,
1493    pub description: String,
1494    /// True for housekeeping tasks the CLI does not surface as user work;
1495    /// hosts should exclude them from activity indicators (CLI 2.1.259+).
1496    #[serde(default, skip_serializing_if = "Option::is_none")]
1497    pub ambient: Option<bool>,
1498}
1499
1500#[derive(Debug, Clone, Serialize, Deserialize)]
1501pub struct SessionStateChangedMessage {
1502    pub state: String,
1503    #[serde(default, skip_serializing_if = "Option::is_none")]
1504    pub uuid: Option<String>,
1505    #[serde(default, skip_serializing_if = "Option::is_none")]
1506    pub session_id: Option<String>,
1507}
1508
1509#[derive(Debug, Clone, Serialize, Deserialize)]
1510pub struct WorkerShuttingDownMessage {
1511    pub reason: String,
1512    #[serde(default, skip_serializing_if = "Option::is_none")]
1513    pub uuid: Option<String>,
1514    #[serde(default, skip_serializing_if = "Option::is_none")]
1515    pub session_id: Option<String>,
1516}
1517
1518#[derive(Debug, Clone, Serialize, Deserialize)]
1519pub struct CommandsChangedMessage {
1520    pub commands: Vec<CommandInfo>,
1521    #[serde(default, skip_serializing_if = "Option::is_none")]
1522    pub uuid: Option<String>,
1523    #[serde(default, skip_serializing_if = "Option::is_none")]
1524    pub session_id: Option<String>,
1525}
1526
1527#[derive(Debug, Clone, Serialize, Deserialize)]
1528pub struct CommandInfo {
1529    pub name: String,
1530    pub description: String,
1531    #[serde(rename = "argumentHint")]
1532    pub argument_hint: String,
1533    #[serde(default, skip_serializing_if = "Option::is_none")]
1534    pub aliases: Option<Vec<String>>,
1535}
1536
1537#[derive(Debug, Clone, Serialize, Deserialize)]
1538pub struct NotificationMessage {
1539    pub key: String,
1540    pub text: String,
1541    pub priority: String,
1542    #[serde(default, skip_serializing_if = "Option::is_none")]
1543    pub color: Option<String>,
1544    #[serde(default, skip_serializing_if = "Option::is_none")]
1545    pub timeout_ms: Option<u64>,
1546    #[serde(default, skip_serializing_if = "Option::is_none")]
1547    pub uuid: Option<String>,
1548    #[serde(default, skip_serializing_if = "Option::is_none")]
1549    pub session_id: Option<String>,
1550}
1551
1552#[derive(Debug, Clone, Serialize, Deserialize)]
1553pub struct FilesPersistedMessage {
1554    pub files: Vec<PersistedFile>,
1555    pub failed: Vec<FailedPersistedFile>,
1556    pub processed_at: String,
1557    #[serde(default, skip_serializing_if = "Option::is_none")]
1558    pub uuid: Option<String>,
1559    #[serde(default, skip_serializing_if = "Option::is_none")]
1560    pub session_id: Option<String>,
1561}
1562
1563#[derive(Debug, Clone, Serialize, Deserialize)]
1564pub struct PersistedFile {
1565    pub filename: String,
1566    pub file_id: String,
1567}
1568
1569#[derive(Debug, Clone, Serialize, Deserialize)]
1570pub struct FailedPersistedFile {
1571    pub filename: String,
1572    pub error: String,
1573}
1574
1575#[derive(Debug, Clone, Serialize, Deserialize)]
1576pub struct MemoryRecallMessage {
1577    pub mode: String,
1578    pub memories: Vec<MemoryRecallItem>,
1579    #[serde(default, skip_serializing_if = "Option::is_none")]
1580    pub uuid: Option<String>,
1581    #[serde(default, skip_serializing_if = "Option::is_none")]
1582    pub session_id: Option<String>,
1583}
1584
1585#[derive(Debug, Clone, Serialize, Deserialize)]
1586pub struct MemoryRecallItem {
1587    pub path: String,
1588    pub scope: String,
1589    #[serde(default, skip_serializing_if = "Option::is_none")]
1590    pub content: Option<String>,
1591}
1592
1593#[derive(Debug, Clone, Serialize, Deserialize)]
1594pub struct ElicitationCompleteMessage {
1595    pub mcp_server_name: String,
1596    pub elicitation_id: String,
1597    #[serde(default, skip_serializing_if = "Option::is_none")]
1598    pub uuid: Option<String>,
1599    #[serde(default, skip_serializing_if = "Option::is_none")]
1600    pub session_id: Option<String>,
1601}
1602
1603#[derive(Debug, Clone, Serialize, Deserialize)]
1604pub struct PermissionDeniedMessage {
1605    pub tool_name: String,
1606    pub tool_use_id: String,
1607    #[serde(default, skip_serializing_if = "Option::is_none")]
1608    pub agent_id: Option<String>,
1609    #[serde(default, skip_serializing_if = "Option::is_none")]
1610    pub decision_reason_type: Option<String>,
1611    #[serde(default, skip_serializing_if = "Option::is_none")]
1612    pub decision_reason: Option<String>,
1613    pub message: String,
1614    #[serde(default, skip_serializing_if = "Option::is_none")]
1615    pub uuid: Option<String>,
1616    #[serde(default, skip_serializing_if = "Option::is_none")]
1617    pub session_id: Option<String>,
1618}
1619
1620#[derive(Debug, Clone, Serialize, Deserialize)]
1621pub struct MirrorErrorMessage {
1622    pub error: String,
1623    pub key: MirrorErrorKey,
1624    #[serde(default, skip_serializing_if = "Option::is_none")]
1625    pub uuid: Option<String>,
1626    #[serde(default, skip_serializing_if = "Option::is_none")]
1627    pub session_id: Option<String>,
1628}
1629
1630#[derive(Debug, Clone, Serialize, Deserialize)]
1631pub struct MirrorErrorKey {
1632    #[serde(rename = "projectKey")]
1633    pub project_key: String,
1634    #[serde(rename = "sessionId")]
1635    pub session_id: String,
1636    #[serde(default, skip_serializing_if = "Option::is_none")]
1637    pub subpath: Option<String>,
1638}
1639
1640#[derive(Debug, Clone, Serialize, Deserialize)]
1641pub struct InformationalMessage {
1642    pub content: String,
1643    pub level: String,
1644    #[serde(default, skip_serializing_if = "Option::is_none")]
1645    pub tool_use_id: Option<String>,
1646    #[serde(default, skip_serializing_if = "Option::is_none")]
1647    pub prevent_continuation: Option<bool>,
1648    #[serde(default, skip_serializing_if = "Option::is_none")]
1649    pub uuid: Option<String>,
1650    #[serde(default, skip_serializing_if = "Option::is_none")]
1651    pub session_id: Option<String>,
1652}
1653
1654/// Plugin info from the init message
1655#[derive(Debug, Clone, Serialize, Deserialize)]
1656pub struct PluginInfo {
1657    /// Plugin name
1658    pub name: String,
1659    /// Path to the plugin on disk
1660    pub path: String,
1661    /// Plugin registry source (e.g., "rust-analyzer-lsp@claude-plugins-official")
1662    #[serde(skip_serializing_if = "Option::is_none")]
1663    pub source: Option<String>,
1664    /// Installed plugin version (e.g., "1.0.0"). Added in CLI 2.1.219.
1665    #[serde(default, skip_serializing_if = "Option::is_none")]
1666    pub version: Option<String>,
1667}
1668
1669/// Plugin load diagnostic reported by system init.
1670#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1671pub struct PluginDiagnostic {
1672    pub plugin: String,
1673    #[serde(rename = "type")]
1674    pub diagnostic_type: String,
1675    pub message: String,
1676}
1677
1678/// Memory paths reported by system init.
1679#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1680pub struct MemoryPaths {
1681    #[serde(default, skip_serializing_if = "Option::is_none")]
1682    pub auto: Option<String>,
1683    #[serde(default, skip_serializing_if = "Option::is_none")]
1684    pub team: Option<String>,
1685    #[serde(flatten)]
1686    pub extra: serde_json::Map<String, Value>,
1687}
1688
1689/// An MCP server config entry that failed validation, reported by system
1690/// init (e.g. a `url` entry with no `type`). The affected server is skipped
1691/// and absent from `InitMessage::mcp_servers`.
1692#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1693pub struct McpServerError {
1694    pub name: String,
1695    /// Stable error category.
1696    #[serde(rename = "type")]
1697    pub error_type: String,
1698    pub message: String,
1699}
1700
1701/// Init system message data - sent at session start
1702#[derive(Debug, Clone, Serialize, Deserialize)]
1703pub struct InitMessage {
1704    /// Session identifier
1705    pub session_id: String,
1706    /// Current working directory
1707    #[serde(skip_serializing_if = "Option::is_none")]
1708    pub cwd: Option<String>,
1709    /// Model being used
1710    #[serde(skip_serializing_if = "Option::is_none")]
1711    pub model: Option<String>,
1712    /// List of available tools
1713    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1714    pub tools: Vec<String>,
1715    /// MCP servers configured
1716    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1717    pub mcp_servers: Vec<Value>,
1718    /// Available slash commands (e.g., "compact", "cost", "review")
1719    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1720    pub slash_commands: Vec<String>,
1721    /// Slash commands only meaningful in a terminal context (CLI 2.1.232+,
1722    /// e.g. "doctor", "color")
1723    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1724    pub terminal_slash_commands: Vec<String>,
1725    /// Available agent types (e.g., "Bash", "Explore", "Plan")
1726    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1727    pub agents: Vec<String>,
1728    /// Installed plugins
1729    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1730    pub plugins: Vec<PluginInfo>,
1731    /// Installed skills
1732    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1733    pub skills: Vec<Value>,
1734    /// Claude Code CLI version
1735    #[serde(skip_serializing_if = "Option::is_none")]
1736    pub claude_code_version: Option<String>,
1737    /// Unix socket path for the harness's inter-session messaging bridge
1738    /// (new in CLI 2.1.232; absent on older CLIs and non-bridged runs)
1739    #[serde(skip_serializing_if = "Option::is_none")]
1740    pub messaging_socket_path: Option<String>,
1741    /// How the API key was sourced
1742    #[serde(skip_serializing_if = "Option::is_none", rename = "apiKeySource")]
1743    pub api_key_source: Option<ApiKeySource>,
1744    /// Output style
1745    #[serde(skip_serializing_if = "Option::is_none")]
1746    pub output_style: Option<OutputStyle>,
1747    /// Permission mode
1748    #[serde(skip_serializing_if = "Option::is_none", rename = "permissionMode")]
1749    pub permission_mode: Option<InitPermissionMode>,
1750
1751    /// Message-level unique identifier
1752    #[serde(skip_serializing_if = "Option::is_none")]
1753    pub uuid: Option<String>,
1754
1755    /// Memory storage paths (e.g., {"auto": "/path/to/memory/"})
1756    #[serde(skip_serializing_if = "Option::is_none")]
1757    pub memory_paths: Option<MemoryPaths>,
1758
1759    /// Fast mode toggle state (e.g., "off")
1760    #[serde(skip_serializing_if = "Option::is_none")]
1761    pub fast_mode_state: Option<String>,
1762
1763    /// Why fast mode can't serve right now. Absent when nothing blocks it.
1764    #[serde(default, skip_serializing_if = "Option::is_none")]
1765    pub fast_mode_disabled_reason: Option<super::result::FastModeDisabledReason>,
1766
1767    /// MCP server config entries (from `--mcp-config`) that failed validation
1768    /// and were skipped. Affected servers are absent from `mcp_servers`.
1769    #[serde(default, skip_serializing_if = "Option::is_none")]
1770    pub mcp_server_errors: Option<Vec<McpServerError>>,
1771
1772    /// Whether analytics collection is disabled for this session.
1773    #[serde(default, skip_serializing_if = "Option::is_none")]
1774    pub analytics_disabled: Option<bool>,
1775
1776    /// Whether product-feedback prompts are disabled for this session.
1777    #[serde(default, skip_serializing_if = "Option::is_none")]
1778    pub product_feedback_disabled: Option<bool>,
1779
1780    /// API beta flags active for the session.
1781    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1782    pub betas: Vec<String>,
1783
1784    /// Open-set protocol capability names supported by this CLI.
1785    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1786    pub capabilities: Vec<String>,
1787
1788    /// Plugin load errors.
1789    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1790    pub plugin_errors: Vec<PluginDiagnostic>,
1791
1792    /// Plugin load warnings.
1793    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1794    pub plugin_warnings: Vec<PluginDiagnostic>,
1795
1796    /// The effort level the session will send on its next request — after env
1797    /// overrides, session state, org caps, and model-support downgrades
1798    /// (`"low"` | `"medium"` | `"high"` | `"xhigh"` | `"max"`). `None` when no
1799    /// effort parameter will be sent, or on CLIs before 2.1.239.
1800    #[serde(default, skip_serializing_if = "Option::is_none")]
1801    pub effort: Option<String>,
1802
1803    /// Only on init frames written by the headless stream-json client of a
1804    /// cloud-hosted session: a per-frame snapshot of the cloud session's id,
1805    /// view URL, device binding, and directory-sync state. Absent in every
1806    /// other mode. Stored as raw JSON (the shape is internal and evolving).
1807    #[serde(default, skip_serializing_if = "Option::is_none")]
1808    pub cloud_session: Option<Value>,
1809
1810    /// The terminal's server-configured `◆ <text>` footer pill, carried so a
1811    /// host UI can render the same pill. Absent when nothing is configured
1812    /// (CLI 2.1.259+).
1813    #[serde(default, skip_serializing_if = "Option::is_none")]
1814    pub footer_indicator: Option<FooterIndicator>,
1815
1816    /// This cloud worker's life (`CLAUDE_CODE_WORKER_EPOCH`): a new number
1817    /// each time the session's worker is started. Absent outside cloud
1818    /// workers and on CLIs before 2.1.259.
1819    #[serde(default, skip_serializing_if = "Option::is_none")]
1820    pub worker_epoch: Option<u64>,
1821
1822    /// Windows only: the absolute path of the PowerShell binary this session
1823    /// runs PowerShell commands with, or `Some(None)` (wire `null`) when
1824    /// none was found. Absent on other platforms and on CLIs before 2.1.259.
1825    #[serde(default, skip_serializing_if = "Option::is_none")]
1826    pub powershell_path: Option<Option<String>>,
1827}
1828
1829/// The server-configured session indicator that the terminal renders as a
1830/// `◆ <text>` pill in the prompt footer, carried on `system/init` and the
1831/// `initialize` response (CLI 2.1.259+).
1832#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1833pub struct FooterIndicator {
1834    /// The label to show — already sanitized to a single line of plain text,
1835    /// exactly as the terminal footer renders it after its `◆` glyph.
1836    pub text: String,
1837}
1838
1839/// Status system message - sent during operations like context compaction
1840#[derive(Debug, Clone, Serialize, Deserialize)]
1841pub struct StatusMessage {
1842    /// Session identifier
1843    pub session_id: String,
1844    /// Current status (e.g., compacting) or null when complete
1845    pub status: Option<StatusMessageStatus>,
1846    /// Unique identifier for this message
1847    #[serde(skip_serializing_if = "Option::is_none")]
1848    pub uuid: Option<String>,
1849    /// Current permission mode when changed mid-session.
1850    #[serde(skip_serializing_if = "Option::is_none", rename = "permissionMode")]
1851    pub permission_mode: Option<InitPermissionMode>,
1852    #[serde(skip_serializing_if = "Option::is_none")]
1853    pub compact_result: Option<String>,
1854    #[serde(skip_serializing_if = "Option::is_none")]
1855    pub compact_error: Option<String>,
1856}
1857
1858/// Compact boundary message - marks where context compaction occurred
1859#[derive(Debug, Clone, Serialize, Deserialize)]
1860pub struct CompactBoundaryMessage {
1861    /// Session identifier
1862    pub session_id: String,
1863    /// Metadata about the compaction
1864    pub compact_metadata: CompactMetadata,
1865    /// Human-readable summary of what was compacted, when the CLI emits one.
1866    ///
1867    /// Also accepted under the `content` / `text` wire keys.
1868    #[serde(
1869        default,
1870        skip_serializing_if = "Option::is_none",
1871        alias = "content",
1872        alias = "text"
1873    )]
1874    pub summary: Option<String>,
1875    /// Number of messages summarized in this compaction pass, when present.
1876    ///
1877    /// Also accepted under the `message_count` wire key.
1878    #[serde(
1879        default,
1880        skip_serializing_if = "Option::is_none",
1881        alias = "message_count"
1882    )]
1883    pub leaf_message_count: Option<u32>,
1884    /// Wall-clock duration of the compaction pass in milliseconds, when present.
1885    #[serde(default, skip_serializing_if = "Option::is_none")]
1886    pub duration_ms: Option<u64>,
1887    /// Unique identifier for this message
1888    #[serde(skip_serializing_if = "Option::is_none")]
1889    pub uuid: Option<String>,
1890    /// Logical parent across the compaction boundary.
1891    #[serde(skip_serializing_if = "Option::is_none")]
1892    pub logical_parent_uuid: Option<Option<String>>,
1893}
1894
1895/// Metadata about context compaction
1896#[derive(Debug, Clone, Serialize, Deserialize)]
1897pub struct CompactMetadata {
1898    /// Number of tokens before compaction
1899    pub pre_tokens: u64,
1900    /// What triggered the compaction
1901    pub trigger: CompactionTrigger,
1902    #[serde(default, skip_serializing_if = "Option::is_none")]
1903    pub post_tokens: Option<u64>,
1904    #[serde(default, skip_serializing_if = "Option::is_none")]
1905    pub cumulative_dropped_tokens: Option<u64>,
1906    #[serde(default, skip_serializing_if = "Option::is_none")]
1907    pub duration_ms: Option<u64>,
1908    #[serde(default, skip_serializing_if = "Option::is_none")]
1909    pub user_context: Option<String>,
1910    #[serde(default, skip_serializing_if = "Option::is_none")]
1911    pub messages_summarized: Option<u64>,
1912    #[serde(default, skip_serializing_if = "Option::is_none")]
1913    pub precomputed: Option<bool>,
1914    #[serde(default, skip_serializing_if = "Option::is_none")]
1915    pub pre_compact_discovered_tools: Option<Vec<String>>,
1916    #[serde(default, skip_serializing_if = "Option::is_none")]
1917    pub preserved_segment: Option<PreservedSegment>,
1918    #[serde(default, skip_serializing_if = "Option::is_none")]
1919    pub preserved_messages: Option<PreservedMessages>,
1920}
1921
1922#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1923pub struct PreservedSegment {
1924    pub head_uuid: String,
1925    pub anchor_uuid: String,
1926    pub tail_uuid: String,
1927}
1928
1929#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1930pub struct PreservedMessages {
1931    pub anchor_uuid: String,
1932    pub uuids: Vec<String>,
1933    #[serde(default, skip_serializing_if = "Option::is_none")]
1934    pub all_uuids: Option<Vec<String>>,
1935}
1936
1937// ---------------------------------------------------------------------------
1938// Task system message types (task_started, task_progress, task_notification)
1939// ---------------------------------------------------------------------------
1940
1941/// Cumulative usage statistics for a background task.
1942#[derive(Debug, Clone, Serialize, Deserialize)]
1943pub struct TaskUsage {
1944    /// Wall-clock milliseconds since the task started.
1945    pub duration_ms: u64,
1946    /// Total number of tool calls made so far.
1947    pub tool_uses: u64,
1948    /// Total tokens consumed so far.
1949    pub total_tokens: u64,
1950}
1951
1952/// The kind of background task.
1953#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1954pub enum TaskType {
1955    /// A sub-agent task (e.g., Explore, Plan).
1956    LocalAgent,
1957    /// A background bash command.
1958    LocalBash,
1959    /// A local workflow task.
1960    LocalWorkflow,
1961    /// A task type not yet known to this version of the crate.
1962    Unknown(String),
1963}
1964
1965impl TaskType {
1966    pub fn as_str(&self) -> &str {
1967        match self {
1968            Self::LocalAgent => "local_agent",
1969            Self::LocalBash => "local_bash",
1970            Self::LocalWorkflow => "local_workflow",
1971            Self::Unknown(s) => s.as_str(),
1972        }
1973    }
1974}
1975
1976impl fmt::Display for TaskType {
1977    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1978        f.write_str(self.as_str())
1979    }
1980}
1981
1982impl From<&str> for TaskType {
1983    fn from(s: &str) -> Self {
1984        match s {
1985            "local_agent" => Self::LocalAgent,
1986            "local_bash" => Self::LocalBash,
1987            "local_workflow" => Self::LocalWorkflow,
1988            other => Self::Unknown(other.to_string()),
1989        }
1990    }
1991}
1992
1993impl Serialize for TaskType {
1994    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1995        serializer.serialize_str(self.as_str())
1996    }
1997}
1998
1999impl<'de> Deserialize<'de> for TaskType {
2000    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2001        let s = String::deserialize(deserializer)?;
2002        Ok(Self::from(s.as_str()))
2003    }
2004}
2005
2006/// Completion status of a background task.
2007#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2008pub enum TaskStatus {
2009    Pending,
2010    Running,
2011    Completed,
2012    Failed,
2013    Killed,
2014    Paused,
2015    Stopped,
2016    Unknown(String),
2017}
2018
2019impl TaskStatus {
2020    pub fn as_str(&self) -> &str {
2021        match self {
2022            Self::Pending => "pending",
2023            Self::Running => "running",
2024            Self::Completed => "completed",
2025            Self::Failed => "failed",
2026            Self::Killed => "killed",
2027            Self::Paused => "paused",
2028            Self::Stopped => "stopped",
2029            Self::Unknown(s) => s.as_str(),
2030        }
2031    }
2032}
2033
2034impl fmt::Display for TaskStatus {
2035    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2036        f.write_str(self.as_str())
2037    }
2038}
2039
2040impl From<&str> for TaskStatus {
2041    fn from(s: &str) -> Self {
2042        match s {
2043            "pending" => Self::Pending,
2044            "running" => Self::Running,
2045            "completed" => Self::Completed,
2046            "failed" => Self::Failed,
2047            "killed" => Self::Killed,
2048            "paused" => Self::Paused,
2049            "stopped" => Self::Stopped,
2050            other => Self::Unknown(other.to_string()),
2051        }
2052    }
2053}
2054
2055impl Serialize for TaskStatus {
2056    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
2057        serializer.serialize_str(self.as_str())
2058    }
2059}
2060
2061impl<'de> Deserialize<'de> for TaskStatus {
2062    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2063        let s = String::deserialize(deserializer)?;
2064        Ok(Self::from(s.as_str()))
2065    }
2066}
2067
2068/// `task_started` system message — emitted once when a background task begins.
2069#[derive(Debug, Clone, Serialize, Deserialize)]
2070pub struct TaskStartedMessage {
2071    pub session_id: String,
2072    pub task_id: String,
2073    #[serde(default, skip_serializing_if = "Option::is_none")]
2074    pub task_type: Option<TaskType>,
2075    #[serde(default, skip_serializing_if = "Option::is_none")]
2076    pub tool_use_id: Option<String>,
2077    pub description: String,
2078    /// The subagent type for `local_agent` tasks (e.g. `general-purpose`,
2079    /// `Explore`). Absent for `local_bash` tasks.
2080    #[serde(default, skip_serializing_if = "Option::is_none")]
2081    pub subagent_type: Option<String>,
2082    /// Whether the task was registered in the background (`true`) or in the
2083    /// foreground with the spawning tool call blocking on it (`false`). A
2084    /// later move to the background arrives as `task_updated`
2085    /// `patch.is_backgrounded`. Set for `local_agent` and `local_bash` tasks
2086    /// (CLI 2.1.239+).
2087    #[serde(default, skip_serializing_if = "Option::is_none")]
2088    pub is_backgrounded: Option<bool>,
2089    /// Nesting depth of a spawned subagent (`local_agent`) task: 1 for a
2090    /// top-level spawn, N+1 when spawned from inside a depth-N agent. Not set
2091    /// on other tasks (CLI 2.1.239+).
2092    #[serde(default, skip_serializing_if = "Option::is_none")]
2093    pub spawn_depth: Option<u32>,
2094    /// The prompt handed to the subagent. Present for `local_agent` tasks.
2095    #[serde(default, skip_serializing_if = "Option::is_none")]
2096    pub prompt: Option<String>,
2097    #[serde(default, skip_serializing_if = "Option::is_none")]
2098    pub workflow_name: Option<String>,
2099    #[serde(default, skip_serializing_if = "Option::is_none")]
2100    pub skip_transcript: Option<bool>,
2101    /// True for housekeeping tasks the CLI does not surface as user work
2102    /// (every `skip_transcript` task, plus auto-started live-update
2103    /// watchers); hosts should exclude them from activity indicators
2104    /// (CLI 2.1.259+).
2105    #[serde(default, skip_serializing_if = "Option::is_none")]
2106    pub ambient: Option<bool>,
2107    pub uuid: String,
2108}
2109
2110/// `task_updated` system message — emitted when a background task's state
2111/// changes (e.g. transitions to `completed`). Carries a partial `patch` of the
2112/// fields that changed rather than the full task record.
2113#[derive(Debug, Clone, Serialize, Deserialize)]
2114pub struct TaskUpdatedMessage {
2115    pub session_id: String,
2116    pub task_id: String,
2117    pub patch: TaskPatch,
2118    pub uuid: String,
2119}
2120
2121/// The partial update carried by a [`TaskUpdatedMessage`]. Every field is
2122/// optional because the CLI only sends the keys that changed.
2123#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2124pub struct TaskPatch {
2125    #[serde(default, skip_serializing_if = "Option::is_none")]
2126    pub status: Option<TaskStatus>,
2127    /// Wall-clock epoch milliseconds when the task finished, when the patch
2128    /// reports completion.
2129    #[serde(default, skip_serializing_if = "Option::is_none")]
2130    pub end_time: Option<u64>,
2131    #[serde(default, skip_serializing_if = "Option::is_none")]
2132    pub description: Option<String>,
2133    #[serde(default, skip_serializing_if = "Option::is_none")]
2134    pub total_paused_ms: Option<u64>,
2135    #[serde(default, skip_serializing_if = "Option::is_none")]
2136    pub error: Option<String>,
2137    #[serde(default, skip_serializing_if = "Option::is_none")]
2138    pub is_backgrounded: Option<bool>,
2139}
2140
2141/// `thinking_tokens` system message — emitted as the model streams extended
2142/// thinking, reporting the running estimate of thinking tokens consumed.
2143#[derive(Debug, Clone, Serialize, Deserialize)]
2144pub struct ThinkingTokensMessage {
2145    pub session_id: String,
2146    /// Running estimate of total thinking tokens for the current turn.
2147    pub estimated_tokens: u64,
2148    /// Increase in the estimate since the previous `thinking_tokens` event.
2149    pub estimated_tokens_delta: u64,
2150    /// Client uuid of the user message that triggered this turn, stamped on
2151    /// every `thinking_tokens` frame of a headless turn so a consumer can
2152    /// attribute thinking progress to the send it answers before any reply
2153    /// frame arrives. Absent on synthetic/scheduled (meta) turns, on turns
2154    /// without a client uuid, on Remote Control sessions, and from CLIs
2155    /// before 2.1.261.
2156    #[serde(default, skip_serializing_if = "Option::is_none")]
2157    pub user_message_uuid: Option<String>,
2158    pub uuid: String,
2159}
2160
2161/// `task_progress` system message — emitted periodically as a background
2162/// agent task executes tools. Not emitted for `local_bash` tasks.
2163#[derive(Debug, Clone, Serialize, Deserialize)]
2164pub struct TaskProgressMessage {
2165    pub session_id: String,
2166    pub task_id: String,
2167    #[serde(default, skip_serializing_if = "Option::is_none")]
2168    pub tool_use_id: Option<String>,
2169    pub description: String,
2170    #[serde(default, skip_serializing_if = "Option::is_none")]
2171    pub last_tool_name: Option<String>,
2172    pub usage: TaskUsage,
2173    /// Subagent type for `local_agent` tasks (e.g. `Explore`).
2174    #[serde(default, skip_serializing_if = "Option::is_none")]
2175    pub subagent_type: Option<String>,
2176    #[serde(default, skip_serializing_if = "Option::is_none")]
2177    pub summary: Option<String>,
2178    pub uuid: String,
2179}
2180
2181/// `task_notification` system message — emitted once when a background
2182/// task completes or fails.
2183#[derive(Debug, Clone, Serialize, Deserialize)]
2184pub struct TaskNotificationMessage {
2185    pub session_id: String,
2186    pub task_id: String,
2187    pub status: TaskStatus,
2188    pub summary: String,
2189    pub output_file: Option<String>,
2190    #[serde(skip_serializing_if = "Option::is_none")]
2191    pub tool_use_id: Option<String>,
2192    #[serde(skip_serializing_if = "Option::is_none")]
2193    pub usage: Option<TaskUsage>,
2194    /// For a backgrounded MCP task that completed, the `resource_link`
2195    /// content blocks of its final result — the files it returned by
2196    /// reference — collected from the raw result before the CLI renders it
2197    /// as text. Join to the originating call via `tool_use_id`. Absent when
2198    /// the result had none or the task is any other type (CLI 2.1.259+).
2199    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2200    pub resource_links: Vec<ResourceLink>,
2201    #[serde(default, skip_serializing_if = "Option::is_none")]
2202    pub skip_transcript: Option<bool>,
2203    /// True for housekeeping tasks the CLI does not surface as user work;
2204    /// hosts should exclude them from activity indicators (CLI 2.1.259+).
2205    #[serde(default, skip_serializing_if = "Option::is_none")]
2206    pub ambient: Option<bool>,
2207    #[serde(skip_serializing_if = "Option::is_none")]
2208    pub uuid: Option<String>,
2209}
2210
2211/// API error category attached to assistant wrapper frames.
2212#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2213pub enum AssistantErrorKind {
2214    AuthenticationFailed,
2215    OauthOrgNotAllowed,
2216    AccountOnHold,
2217    BillingError,
2218    RateLimit,
2219    Overloaded,
2220    InvalidRequest,
2221    ModelNotFound,
2222    ServerError,
2223    UnknownError,
2224    MaxOutputTokens,
2225    Unknown(String),
2226}
2227
2228impl AssistantErrorKind {
2229    pub fn as_str(&self) -> &str {
2230        match self {
2231            Self::AuthenticationFailed => "authentication_failed",
2232            Self::OauthOrgNotAllowed => "oauth_org_not_allowed",
2233            Self::AccountOnHold => "account_on_hold",
2234            Self::BillingError => "billing_error",
2235            Self::RateLimit => "rate_limit",
2236            Self::Overloaded => "overloaded",
2237            Self::InvalidRequest => "invalid_request",
2238            Self::ModelNotFound => "model_not_found",
2239            Self::ServerError => "server_error",
2240            Self::UnknownError => "unknown",
2241            Self::MaxOutputTokens => "max_output_tokens",
2242            Self::Unknown(s) => s.as_str(),
2243        }
2244    }
2245}
2246
2247impl fmt::Display for AssistantErrorKind {
2248    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2249        f.write_str(self.as_str())
2250    }
2251}
2252
2253impl From<&str> for AssistantErrorKind {
2254    fn from(s: &str) -> Self {
2255        match s {
2256            "authentication_failed" => Self::AuthenticationFailed,
2257            "oauth_org_not_allowed" => Self::OauthOrgNotAllowed,
2258            "account_on_hold" => Self::AccountOnHold,
2259            "billing_error" => Self::BillingError,
2260            "rate_limit" => Self::RateLimit,
2261            "overloaded" => Self::Overloaded,
2262            "invalid_request" => Self::InvalidRequest,
2263            "model_not_found" => Self::ModelNotFound,
2264            "server_error" => Self::ServerError,
2265            "unknown" => Self::UnknownError,
2266            "max_output_tokens" => Self::MaxOutputTokens,
2267            other => Self::Unknown(other.to_string()),
2268        }
2269    }
2270}
2271
2272impl Serialize for AssistantErrorKind {
2273    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
2274        serializer.serialize_str(self.as_str())
2275    }
2276}
2277
2278impl<'de> Deserialize<'de> for AssistantErrorKind {
2279    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2280        let s = String::deserialize(deserializer)?;
2281        Ok(Self::from(s.as_str()))
2282    }
2283}
2284
2285/// `code_change_published` system message — the session is now associated
2286/// with a published code change (a pull/merge request). Fires on creation and
2287/// whenever the session contributes to an existing one, so bind on every
2288/// event; re-emission for the same URL is possible and idempotent. Values are
2289/// scraped from captured command output — treat them as a binding hint and
2290/// verify against the forge before routing authenticated requests.
2291#[derive(Debug, Clone, Serialize, Deserialize)]
2292pub struct CodeChangePublishedMessage {
2293    /// Forge classification derived from the URL's shape (`github`,
2294    /// `github-enterprise`, `gitlab`, `bitbucket`, `gerrit` today). Open set
2295    /// — treat an unknown value as a valid provider, never as an error.
2296    pub provider: String,
2297    /// Web URL of the pull/merge request. Unverified.
2298    pub url: String,
2299    /// Repository path from the URL (`owner/name` on GitHub; may carry more
2300    /// segments on GitLab).
2301    pub repo: String,
2302    /// Provider-native change identifier — the PR/MR number as a string.
2303    pub identifier: String,
2304    /// What the session did that produced this announcement: the flag-aware
2305    /// `gh pr` verb it ran (`"created"`, `"edited"`, `"merged"`,
2306    /// `"commented"`, `"closed"`, `"reopened"`, `"ready"`, `"draft"`,
2307    /// `"auto-merge-enabled"`, `"auto-merge-disabled"`), `"pushed"` for a
2308    /// push to a branch that has a PR, `"checked-out"` for `gh pr checkout`,
2309    /// or `"started"` for the open change on the branch a Claude Desktop
2310    /// session began on. Always sent by current producers (CLI 2.1.239+),
2311    /// absent only from older ones. Open set — treat unknown values as valid.
2312    #[serde(default, skip_serializing_if = "Option::is_none")]
2313    pub action: Option<String>,
2314    /// The session's working branch when it produced the change. Sent only
2315    /// for providers whose changes have no head branch of their own
2316    /// (`gerrit`), so a host can place the change on that checkout; absent
2317    /// for every other provider (CLI 2.1.259+).
2318    #[serde(default, skip_serializing_if = "Option::is_none")]
2319    pub branch: Option<String>,
2320    pub uuid: String,
2321    pub session_id: String,
2322}
2323
2324/// `vcs_state_changed` system message — a harness-observed shell command
2325/// mutated repository state. A cache-invalidation signal, deliberately
2326/// payload-free beyond classification: consumers re-read state (branch, head,
2327/// PR status) instead of decoding the event.
2328#[derive(Debug, Clone, Serialize, Deserialize)]
2329pub struct VcsStateChangedMessage {
2330    /// What class of mutation was observed. New kinds may be added — treat an
2331    /// unrecognized kind exactly like a recognized one (something changed).
2332    pub kind: VcsMutationKind,
2333    /// The session's working directory — a hint, not necessarily the mutated
2334    /// repo's path (`git -C` or an inner `cd` mutates elsewhere).
2335    pub cwd: String,
2336    /// The branch a commit landed on or a push updated. Commit and push
2337    /// events carry it; a command that pushed several branches emits one push
2338    /// event per branch. A best-effort hint: absent whenever attribution is
2339    /// uncertain, and never a required key (CLI 2.1.239+).
2340    #[serde(default, skip_serializing_if = "Option::is_none")]
2341    pub branch: Option<String>,
2342    pub uuid: String,
2343    pub session_id: String,
2344}
2345
2346/// Mutation class carried by a [`VcsStateChangedMessage`].
2347#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2348pub enum VcsMutationKind {
2349    Commit,
2350    Push,
2351    Merge,
2352    Rebase,
2353    /// A kind not yet known to this version of the crate.
2354    Unknown(String),
2355}
2356
2357impl VcsMutationKind {
2358    pub fn as_str(&self) -> &str {
2359        match self {
2360            Self::Commit => "commit",
2361            Self::Push => "push",
2362            Self::Merge => "merge",
2363            Self::Rebase => "rebase",
2364            Self::Unknown(s) => s.as_str(),
2365        }
2366    }
2367}
2368
2369impl fmt::Display for VcsMutationKind {
2370    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2371        f.write_str(self.as_str())
2372    }
2373}
2374
2375impl From<&str> for VcsMutationKind {
2376    fn from(s: &str) -> Self {
2377        match s {
2378            "commit" => Self::Commit,
2379            "push" => Self::Push,
2380            "merge" => Self::Merge,
2381            "rebase" => Self::Rebase,
2382            other => Self::Unknown(other.to_string()),
2383        }
2384    }
2385}
2386
2387impl Serialize for VcsMutationKind {
2388    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
2389        serializer.serialize_str(self.as_str())
2390    }
2391}
2392
2393impl<'de> Deserialize<'de> for VcsMutationKind {
2394    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2395        let s = String::deserialize(deserializer)?;
2396        Ok(Self::from(s.as_str()))
2397    }
2398}
2399
2400/// `system/feedback_draft_queued` — a feedback draft was queued for submission.
2401#[derive(Debug, Clone, Serialize, Deserialize)]
2402pub struct FeedbackDraftQueuedMessage {
2403    pub draft_id: String,
2404    pub draft_type: String,
2405    pub title: String,
2406    pub details_preview: String,
2407    #[serde(default, skip_serializing_if = "Option::is_none")]
2408    pub uuid: Option<String>,
2409    #[serde(default, skip_serializing_if = "Option::is_none")]
2410    pub session_id: Option<String>,
2411    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
2412    pub extra: serde_json::Map<String, Value>,
2413}
2414
2415/// `system/cloud_session_delta` — written only by the headless stream-json
2416/// client of a cloud-hosted session (the same client that puts
2417/// `cloud_session` on its init frames): the session's status changed between
2418/// two inits. Only after the first init; at most a few per second; none when
2419/// nothing differs. An init is always a complete snapshot, so a host that
2420/// (re)attaches resynchronises from the first init it reads and applies
2421/// these on top. Display-only (CLI 2.1.260+).
2422#[derive(Debug, Clone, Serialize, Deserialize)]
2423pub struct CloudSessionDeltaMessage {
2424    /// Rises by one with each of these frames this client writes (1 for the
2425    /// first); never reset by an init. A reader keeps the highest it has
2426    /// applied and drops a lower one.
2427    pub seq: u64,
2428    /// The top-level keys of `cloud_session` whose value differs from the
2429    /// last block this client wrote, e.g. `["serving"]`; never empty. A hint
2430    /// for what to redraw — the block is complete either way.
2431    pub changed: Vec<String>,
2432    /// The whole block exactly as the next init would carry it (see
2433    /// [`InitMessage::cloud_session`]): replace the held copy, do not merge.
2434    /// Stored as raw JSON (the shape is internal and evolving).
2435    pub cloud_session: Value,
2436    pub uuid: String,
2437    pub session_id: String,
2438    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
2439    pub extra: serde_json::Map<String, Value>,
2440}
2441
2442/// `{id, name}` of an original `Batch*` tool_use block, carried in
2443/// [`AssistantMessage::batch_tool_uses`].
2444#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2445pub struct BatchToolUse {
2446    pub id: String,
2447    pub name: String,
2448}
2449
2450/// Structured twin of the `/context` report, carried as
2451/// [`AssistantMessage::context_usage`] — the data a client needs to render
2452/// the context-usage card without parsing the markdown table. Evolves
2453/// additively; a breaking reshape would ship as a sibling field.
2454#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2455pub struct ContextUsage {
2456    /// Main-loop model the usage was computed for.
2457    pub model: String,
2458    /// Estimated tokens in use, unclamped — may exceed `raw_max_tokens` when
2459    /// over limit.
2460    pub total_tokens: u64,
2461    /// The window usage is measured against: the resolved autocompact window —
2462    /// the model's believed limit, or a smaller compaction-policy window.
2463    pub raw_max_tokens: u64,
2464    /// Rounded `total_tokens / raw_max_tokens`, 0–100+.
2465    pub percentage: u64,
2466    /// Present when `total_tokens` exceeds `raw_max_tokens`.
2467    #[serde(default, skip_serializing_if = "Option::is_none")]
2468    pub over_limit: Option<ContextOverLimit>,
2469    /// Usage-by-category rows (`Messages`, `System prompt`, …).
2470    #[serde(default)]
2471    pub categories: Vec<ContextCategory>,
2472    /// Per-tool token contributions of MCP tools.
2473    #[serde(default)]
2474    pub mcp_tools: Vec<ContextMcpTool>,
2475    /// Per-file token contributions of memory files.
2476    #[serde(default)]
2477    pub memory_files: Vec<ContextMemoryFile>,
2478    /// Per-agent token contributions of agent definitions.
2479    #[serde(default)]
2480    pub agents: Vec<ContextAgent>,
2481    /// Per-skill token contributions. Omitted when no skills contribute.
2482    #[serde(default, skip_serializing_if = "Option::is_none")]
2483    pub skills: Option<Vec<ContextSkill>>,
2484}
2485
2486/// Why and by how much a [`ContextUsage`] exceeds its window.
2487#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2488pub struct ContextOverLimit {
2489    pub tokens_over: u64,
2490    /// How the window was resolved: `"hard_limit"` (the model's believed
2491    /// limit) or `"compaction_window"` (a compaction-policy window).
2492    pub kind: String,
2493}
2494
2495/// One row of the `/context` usage-by-category breakdown.
2496#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2497pub struct ContextCategory {
2498    /// Display name of the row as the CLI renders it, e.g. `"Messages"`.
2499    /// Use `kind` (not this name) to classify the row.
2500    pub name: String,
2501    pub tokens: u64,
2502    /// What the row is: `"used"` content occupies the window; `"free"` is the
2503    /// remaining window; `"buffer"` is the compaction reserve; `"deferred"`
2504    /// rows are out-of-window tool schemas, excluded from usage math.
2505    pub kind: String,
2506}
2507
2508/// An MCP tool's token contribution, in [`ContextUsage::mcp_tools`].
2509#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2510pub struct ContextMcpTool {
2511    /// Wire name, e.g. `"mcp__linear__create_issue"`.
2512    pub name: String,
2513    pub server_name: String,
2514    pub tokens: u64,
2515}
2516
2517/// A memory file's token contribution, in [`ContextUsage::memory_files`].
2518#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2519pub struct ContextMemoryFile {
2520    pub path: String,
2521    /// Display label of the memory-file source, e.g. `"Project"` or `"User"`.
2522    #[serde(rename = "type")]
2523    pub file_type: String,
2524    pub tokens: u64,
2525}
2526
2527/// An agent definition's token contribution, in [`ContextUsage::agents`].
2528#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2529pub struct ContextAgent {
2530    pub agent_type: String,
2531    /// Raw source identifier, e.g. `"projectSettings"`, `"plugin"`.
2532    pub source: String,
2533    pub tokens: u64,
2534}
2535
2536/// A skill's token contribution, in [`ContextUsage::skills`].
2537#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2538pub struct ContextSkill {
2539    pub name: String,
2540    /// Raw source identifier, e.g. `"userSettings"`, `"plugin"`.
2541    pub source: String,
2542    #[serde(default, skip_serializing_if = "Option::is_none")]
2543    pub plugin_name: Option<String>,
2544    pub tokens: u64,
2545}
2546
2547/// Display metadata for a tool-use block carried on the assistant wrapper.
2548#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2549pub struct ToolUseMeta {
2550    pub id: String,
2551    pub display_name: String,
2552    #[serde(default, skip_serializing_if = "Option::is_none")]
2553    pub server_display_name: Option<String>,
2554    #[serde(default, skip_serializing_if = "Option::is_none")]
2555    pub icon_url: Option<String>,
2556}
2557
2558/// Assistant message
2559#[derive(Debug, Clone, Serialize, Deserialize)]
2560pub struct AssistantMessage {
2561    pub message: AssistantMessageContent,
2562    #[serde(alias = "sessionId")]
2563    pub session_id: String,
2564    #[serde(skip_serializing_if = "Option::is_none")]
2565    pub uuid: Option<String>,
2566    #[serde(skip_serializing_if = "Option::is_none")]
2567    pub parent_tool_use_id: Option<String>,
2568    /// Anthropic API request id that produced this message (e.g. `req_...`).
2569    #[serde(skip_serializing_if = "Option::is_none")]
2570    pub request_id: Option<String>,
2571    /// Client uuid of the user message that triggered this turn, stamped on
2572    /// the turn's first reply frame only so a consumer can bind the reply to
2573    /// the send it answers without waiting for the result. Absent on every
2574    /// later frame of the turn, on subagent frames, on synthetic/scheduled
2575    /// (meta) turns, and from CLIs before 2.1.259.
2576    #[serde(default, skip_serializing_if = "Option::is_none")]
2577    pub user_message_uuid: Option<String>,
2578    /// Client uuids of every user message whose prompt this turn has consumed
2579    /// so far, in consumption order — all members of a prompt batch the host
2580    /// merged into this one turn. Always contains `user_message_uuid`; at
2581    /// most 64 entries. Present exactly when `user_message_uuid` is; absent
2582    /// from CLIs before 2.1.259 (fall back to `user_message_uuid`).
2583    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2584    pub user_message_uuids: Vec<String>,
2585    /// Subagent type, when this assistant message was produced inside a
2586    /// `local_agent` subagent (e.g. `general-purpose`, `Explore`).
2587    #[serde(skip_serializing_if = "Option::is_none")]
2588    pub subagent_type: Option<String>,
2589    /// Short description of the subagent task, present alongside `subagent_type`.
2590    #[serde(skip_serializing_if = "Option::is_none")]
2591    pub task_description: Option<String>,
2592    #[serde(skip_serializing_if = "Option::is_none")]
2593    pub error: Option<AssistantErrorKind>,
2594    /// True when this message was truncated by an interrupt/abort before the
2595    /// stream completed — `stop_reason` was never received and the content
2596    /// may end mid-word. Absent on normally completed messages.
2597    #[serde(default, skip_serializing_if = "Option::is_none")]
2598    pub aborted: Option<bool>,
2599    /// True when this turn continued the preceding truncated assistant turn
2600    /// inside its trailing signed thinking block (max-output-tokens
2601    /// recovery). Histories replayed through the bridge must carry the flag
2602    /// back so the normalizer keeps the run's prefix on the wire.
2603    #[serde(default, skip_serializing_if = "Option::is_none")]
2604    pub resumed_from_incomplete_thinking: Option<bool>,
2605    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2606    pub supersedes: Vec<String>,
2607    #[serde(skip_serializing_if = "Option::is_none")]
2608    pub timestamp: Option<String>,
2609    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2610    pub tool_use_meta: Vec<ToolUseMeta>,
2611    /// `{id, name}` of the original `Batch*` tool_use block(s) for a message
2612    /// whose content was decomposed into synthetic v1 tool_use blocks.
2613    /// Round-tripped so a replayed history reassembles the batch block on the
2614    /// wire. Wrapper-level sibling — never inside `message.content`.
2615    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2616    pub batch_tool_uses: Vec<BatchToolUse>,
2617    /// `tool_use.input` exactly as the API produced it, keyed by `tool_use`
2618    /// id, for a message whose `message.content` carries the
2619    /// client-normalized input. Round-tripped so a replayed history echoes
2620    /// each earlier tool call back to the API as the API emitted it.
2621    /// Wrapper-level sibling — never inside `message.content` (CLI 2.1.259+).
2622    #[serde(default, skip_serializing_if = "Option::is_none")]
2623    pub wire_tool_inputs: Option<serde_json::Map<String, Value>>,
2624    /// The originating `system/local_command` row's wire-form content,
2625    /// carried on the loop-synthesized local-command twin so a bridge/SDK
2626    /// history replay rebuilds the internal system row instead of dropping
2627    /// the output. Wrapper-level sibling — never inside `message.content`
2628    /// (CLI 2.1.259+).
2629    #[serde(default, skip_serializing_if = "Option::is_none")]
2630    pub local_command_source: Option<String>,
2631    /// Ascending zero-based indexes into `message.content` of the thinking
2632    /// blocks whose signature the server tagged as narration (server
2633    /// summaries of the prose between tool calls, not the model's own
2634    /// reasoning), so a renderer can label them as summaries without decoding
2635    /// the signature envelope. Fail-closed: an unparseable or legacy
2636    /// signature is not listed. Omitted when the frame has no such block and
2637    /// by CLIs before 2.1.260; treat unlisted thinking blocks as ordinary
2638    /// thinking. Wrapper-level sibling — never inside `message.content`.
2639    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2640    pub narration_block_indexes: Vec<usize>,
2641    /// Structured twin of the `/context` report, carried on the synthetic
2642    /// assistant message that delivers the markdown table. Present only on
2643    /// `/context` results from CLIs new enough to attach it (2.1.239+).
2644    #[serde(default, skip_serializing_if = "Option::is_none")]
2645    pub context_usage: Option<ContextUsage>,
2646    #[serde(default, skip_serializing_if = "Option::is_none")]
2647    pub is_meta: Option<bool>,
2648    #[serde(default, skip_serializing_if = "Option::is_none")]
2649    pub is_virtual: Option<bool>,
2650    #[serde(default, skip_serializing_if = "Option::is_none")]
2651    pub is_api_error_message: Option<bool>,
2652    #[serde(skip_serializing_if = "Option::is_none")]
2653    pub api_error_status: Option<u16>,
2654    #[serde(skip_serializing_if = "Option::is_none")]
2655    pub api_error: Option<String>,
2656    #[serde(skip_serializing_if = "Option::is_none")]
2657    pub error_details: Option<String>,
2658    #[serde(skip_serializing_if = "Option::is_none")]
2659    pub advisor_model: Option<String>,
2660    #[serde(skip_serializing_if = "Option::is_none")]
2661    pub attribution_agent: Option<String>,
2662    #[serde(skip_serializing_if = "Option::is_none")]
2663    pub attribution_skill: Option<String>,
2664    #[serde(skip_serializing_if = "Option::is_none")]
2665    pub attribution_plugin: Option<String>,
2666    #[serde(skip_serializing_if = "Option::is_none")]
2667    pub attribution_mcp_server: Option<String>,
2668    #[serde(skip_serializing_if = "Option::is_none")]
2669    pub attribution_mcp_tool: Option<String>,
2670}
2671
2672/// Nested message content for assistant messages
2673#[derive(Debug, Clone, Serialize, Deserialize)]
2674pub struct AssistantMessageContent {
2675    pub id: String,
2676    /// The Anthropic API message type — always `"message"`.
2677    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
2678    pub message_type: Option<String>,
2679    pub role: MessageRole,
2680    pub model: String,
2681    pub content: Vec<ContentBlock>,
2682    #[serde(skip_serializing_if = "Option::is_none")]
2683    pub stop_reason: Option<StopReason>,
2684    #[serde(skip_serializing_if = "Option::is_none")]
2685    pub stop_sequence: Option<String>,
2686    #[serde(skip_serializing_if = "Option::is_none")]
2687    pub usage: Option<AssistantUsage>,
2688    /// Details about why generation stopped
2689    #[serde(skip_serializing_if = "Option::is_none")]
2690    pub stop_details: Option<Value>,
2691    /// Context management metadata
2692    #[serde(skip_serializing_if = "Option::is_none")]
2693    pub context_management: Option<Value>,
2694}
2695
2696/// Usage information for assistant messages
2697#[derive(Debug, Clone, Serialize, Deserialize)]
2698pub struct AssistantUsage {
2699    /// Number of input tokens
2700    #[serde(default)]
2701    pub input_tokens: u32,
2702
2703    /// Number of output tokens
2704    #[serde(default)]
2705    pub output_tokens: u32,
2706
2707    /// Tokens used to create cache
2708    #[serde(default)]
2709    pub cache_creation_input_tokens: u32,
2710
2711    /// Tokens read from cache
2712    #[serde(default)]
2713    pub cache_read_input_tokens: u32,
2714
2715    /// Service tier used (e.g., "standard")
2716    #[serde(skip_serializing_if = "Option::is_none")]
2717    pub service_tier: Option<String>,
2718
2719    /// Detailed cache creation breakdown
2720    #[serde(skip_serializing_if = "Option::is_none")]
2721    pub cache_creation: Option<CacheCreationDetails>,
2722
2723    /// Inference geography (e.g., "not_available")
2724    #[serde(skip_serializing_if = "Option::is_none")]
2725    pub inference_geo: Option<String>,
2726}
2727
2728/// Detailed cache creation information
2729#[derive(Debug, Clone, Serialize, Deserialize)]
2730pub struct CacheCreationDetails {
2731    /// Ephemeral 1-hour input tokens
2732    #[serde(default)]
2733    pub ephemeral_1h_input_tokens: u32,
2734
2735    /// Ephemeral 5-minute input tokens
2736    #[serde(default)]
2737    pub ephemeral_5m_input_tokens: u32,
2738}
2739
2740#[cfg(test)]
2741mod tests {
2742    use crate::io::ClaudeOutput;
2743
2744    #[test]
2745    fn test_subagent_usage_rollup_accumulates_task_results() {
2746        use super::SubagentUsageRollup;
2747
2748        let mut rollup = SubagentUsageRollup::default();
2749
2750        let task_result = r#"{"type":"user","message":{"role":"user","content":[]},"session_id":"7fbc568e-2bd6-45aa-b217-a1cf80004ba1","tool_use_result":{"status":"completed","prompt":"Compute 6 times 7.","agentId":"ab52f22445470d454","agentType":"general-purpose","resolvedModel":"claude-sonnet-4-6","totalDurationMs":1853,"totalTokens":10201,"totalToolUseCount":3}}"#;
2751        let output: ClaudeOutput = serde_json::from_str(task_result).unwrap();
2752        assert!(rollup.observe(&output));
2753        assert_eq!(rollup.subagent_tokens, 10201);
2754        assert_eq!(rollup.agent_count, 1);
2755        assert_eq!(rollup.tool_uses, 3);
2756        assert_eq!(rollup.duration_ms, 1853);
2757
2758        // Replayed frame with the same agentId is counted once.
2759        assert!(!rollup.observe(&output));
2760        assert_eq!(rollup.agent_count, 1);
2761        assert_eq!(rollup.subagent_tokens, 10201);
2762
2763        // A second agent accumulates.
2764        let second = r#"{"type":"user","message":{"role":"user","content":[]},"session_id":"7fbc568e-2bd6-45aa-b217-a1cf80004ba1","tool_use_result":{"status":"completed","agentId":"ffff00001111","totalDurationMs":100,"totalTokens":500,"totalToolUseCount":1}}"#;
2765        let output: ClaudeOutput = serde_json::from_str(second).unwrap();
2766        assert!(rollup.observe(&output));
2767        assert_eq!(rollup.agent_count, 2);
2768        assert_eq!(rollup.subagent_tokens, 10701);
2769    }
2770
2771    #[test]
2772    fn test_subagent_usage_rollup_ignores_non_task_results() {
2773        use super::SubagentUsageRollup;
2774
2775        let mut rollup = SubagentUsageRollup::default();
2776
2777        // A ToolSearch tool_use_result parses as an all-None SubagentResult;
2778        // it must not count as a subagent.
2779        let tool_search = r#"{"type":"user","message":{"role":"user","content":[]},"session_id":"7fbc568e-2bd6-45aa-b217-a1cf80004ba1","tool_use_result":{"matches":["TaskCreate"],"query":"select:TaskCreate","total_deferred_tools":27}}"#;
2780        let output: ClaudeOutput = serde_json::from_str(tool_search).unwrap();
2781        assert!(!rollup.observe(&output));
2782
2783        // Plain user message without tool_use_result.
2784        let plain = r#"{"type":"user","message":{"role":"user","content":[]},"session_id":"7fbc568e-2bd6-45aa-b217-a1cf80004ba1"}"#;
2785        let output: ClaudeOutput = serde_json::from_str(plain).unwrap();
2786        assert!(!rollup.observe(&output));
2787
2788        // Non-user frames are ignored.
2789        let system = r#"{"type":"system","subtype":"status","status":null,"session_id":"7fbc568e-2bd6-45aa-b217-a1cf80004ba1"}"#;
2790        let output: ClaudeOutput = serde_json::from_str(system).unwrap();
2791        assert!(!rollup.observe(&output));
2792
2793        assert_eq!(rollup, SubagentUsageRollup::default());
2794    }
2795
2796    #[test]
2797    fn test_subagent_usage_rollup_over_captured_session() {
2798        use super::SubagentUsageRollup;
2799
2800        let mut rollup = SubagentUsageRollup::default();
2801        let fixture =
2802            include_str!("../../test_cases/subagent_sessions/general_purpose_compute.jsonl");
2803        for line in fixture.lines().filter(|l| !l.trim().is_empty()) {
2804            if let Ok(output) = serde_json::from_str::<ClaudeOutput>(line) {
2805                rollup.observe(&output);
2806            }
2807        }
2808        assert_eq!(rollup.agent_count, 1);
2809        assert_eq!(rollup.subagent_tokens, 10201);
2810    }
2811
2812    #[test]
2813    fn test_system_message_init() {
2814        let json = r#"{
2815            "type": "system",
2816            "subtype": "init",
2817            "session_id": "test-session-123",
2818            "cwd": "/home/user/project",
2819            "model": "claude-sonnet-4",
2820            "tools": ["Bash", "Read", "Write"],
2821            "mcp_servers": [],
2822            "slash_commands": ["compact", "cost", "review"],
2823            "agents": ["Bash", "Explore", "Plan"],
2824            "plugins": [{"name": "rust-analyzer-lsp", "path": "/home/user/.claude/plugins/rust-analyzer-lsp/1.0.0"}],
2825            "skills": [],
2826            "claude_code_version": "2.1.15",
2827            "apiKeySource": "none",
2828            "output_style": "default",
2829            "permissionMode": "default"
2830        }"#;
2831
2832        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
2833        if let ClaudeOutput::System(sys) = output {
2834            assert!(sys.is_init());
2835            assert!(!sys.is_status());
2836            assert!(!sys.is_compact_boundary());
2837
2838            let init = sys.as_init().expect("Should parse as init");
2839            assert_eq!(init.session_id, "test-session-123");
2840            assert_eq!(init.cwd, Some("/home/user/project".to_string()));
2841            assert_eq!(init.model, Some("claude-sonnet-4".to_string()));
2842            assert_eq!(init.tools, vec!["Bash", "Read", "Write"]);
2843            assert_eq!(init.slash_commands, vec!["compact", "cost", "review"]);
2844            assert_eq!(init.agents, vec!["Bash", "Explore", "Plan"]);
2845            assert_eq!(init.plugins.len(), 1);
2846            assert_eq!(init.plugins[0].name, "rust-analyzer-lsp");
2847            assert_eq!(init.claude_code_version, Some("2.1.15".to_string()));
2848            assert_eq!(init.api_key_source, Some(super::ApiKeySource::None));
2849            assert_eq!(init.output_style, Some(super::OutputStyle::Default));
2850            assert_eq!(
2851                init.permission_mode,
2852                Some(super::InitPermissionMode::Default)
2853            );
2854        } else {
2855            panic!("Expected System message");
2856        }
2857    }
2858
2859    #[test]
2860    fn test_system_message_init_from_real_capture() {
2861        let json = include_str!("../../test_cases/tool_use_captures/tool_msg_0.json");
2862        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
2863        if let ClaudeOutput::System(sys) = output {
2864            let init = sys.as_init().expect("Should parse real init capture");
2865            assert_eq!(init.slash_commands.len(), 8);
2866            assert!(init.slash_commands.contains(&"compact".to_string()));
2867            assert!(init.slash_commands.contains(&"review".to_string()));
2868            assert_eq!(init.agents.len(), 5);
2869            assert!(init.agents.contains(&"Bash".to_string()));
2870            assert!(init.agents.contains(&"Explore".to_string()));
2871            assert_eq!(init.plugins.len(), 1);
2872            assert_eq!(init.plugins[0].name, "rust-analyzer-lsp");
2873            assert_eq!(init.claude_code_version, Some("2.1.15".to_string()));
2874        } else {
2875            panic!("Expected System message");
2876        }
2877    }
2878
2879    #[test]
2880    fn test_system_message_status() {
2881        let json = r#"{
2882            "type": "system",
2883            "subtype": "status",
2884            "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
2885            "status": "compacting",
2886            "uuid": "32eb9f9d-5ef7-47ff-8fce-bbe22fe7ed93"
2887        }"#;
2888
2889        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
2890        if let ClaudeOutput::System(sys) = output {
2891            assert!(sys.is_status());
2892            assert!(!sys.is_init());
2893
2894            let status = sys.as_status().expect("Should parse as status");
2895            assert_eq!(status.session_id, "879c1a88-3756-4092-aa95-0020c4ed9692");
2896            assert_eq!(status.status, Some(super::StatusMessageStatus::Compacting));
2897            assert_eq!(
2898                status.uuid,
2899                Some("32eb9f9d-5ef7-47ff-8fce-bbe22fe7ed93".to_string())
2900            );
2901        } else {
2902            panic!("Expected System message");
2903        }
2904    }
2905
2906    #[test]
2907    fn test_system_message_status_null() {
2908        let json = r#"{
2909            "type": "system",
2910            "subtype": "status",
2911            "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
2912            "status": null,
2913            "uuid": "92d9637e-d00e-418e-acd2-a504e3861c6a"
2914        }"#;
2915
2916        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
2917        if let ClaudeOutput::System(sys) = output {
2918            let status = sys.as_status().expect("Should parse as status");
2919            assert_eq!(status.status, None);
2920        } else {
2921            panic!("Expected System message");
2922        }
2923    }
2924
2925    #[test]
2926    fn test_system_message_task_started() {
2927        let json = r#"{
2928            "type": "system",
2929            "subtype": "task_started",
2930            "session_id": "9abbc466-dad0-4b8e-b6b0-cad5eb7a16b9",
2931            "task_id": "b6daf3f",
2932            "task_type": "local_bash",
2933            "tool_use_id": "toolu_011rfSTFumpJZdCCfzeD7jaS",
2934            "description": "Wait for CI on PR #12",
2935            "uuid": "c4243261-c128-4747-b8c3-5e1c7c10eeb8"
2936        }"#;
2937
2938        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
2939        if let ClaudeOutput::System(sys) = output {
2940            assert!(sys.is_task_started());
2941            assert!(!sys.is_task_progress());
2942            assert!(!sys.is_task_notification());
2943
2944            let task = sys.as_task_started().expect("Should parse as task_started");
2945            assert_eq!(task.session_id, "9abbc466-dad0-4b8e-b6b0-cad5eb7a16b9");
2946            assert_eq!(task.task_id, "b6daf3f");
2947            assert_eq!(task.task_type, Some(super::TaskType::LocalBash));
2948            assert_eq!(
2949                task.tool_use_id.as_deref(),
2950                Some("toolu_011rfSTFumpJZdCCfzeD7jaS")
2951            );
2952            assert_eq!(task.description, "Wait for CI on PR #12");
2953        } else {
2954            panic!("Expected System message");
2955        }
2956    }
2957
2958    #[test]
2959    fn test_system_message_task_started_agent() {
2960        let json = r#"{
2961            "type": "system",
2962            "subtype": "task_started",
2963            "session_id": "bff4f716-17c1-4255-ab7b-eea9d33824e3",
2964            "task_id": "a4a7e0906e5fc64cc",
2965            "task_type": "local_agent",
2966            "tool_use_id": "toolu_01SFz9FwZ1cYgCSy8vRM7wep",
2967            "description": "Explore Scene/ArrayScene duplication",
2968            "uuid": "85a39f5a-e4d4-47f7-9a6d-1125f1a8035f"
2969        }"#;
2970
2971        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
2972        if let ClaudeOutput::System(sys) = output {
2973            let task = sys.as_task_started().expect("Should parse as task_started");
2974            assert_eq!(task.task_type, Some(super::TaskType::LocalAgent));
2975            assert_eq!(task.task_id, "a4a7e0906e5fc64cc");
2976        } else {
2977            panic!("Expected System message");
2978        }
2979    }
2980
2981    #[test]
2982    fn test_system_message_task_progress() {
2983        let json = r#"{
2984            "type": "system",
2985            "subtype": "task_progress",
2986            "session_id": "bff4f716-17c1-4255-ab7b-eea9d33824e3",
2987            "task_id": "a4a7e0906e5fc64cc",
2988            "tool_use_id": "toolu_01SFz9FwZ1cYgCSy8vRM7wep",
2989            "description": "Reading src/jplephem/chebyshev.rs",
2990            "last_tool_name": "Read",
2991            "usage": {
2992                "duration_ms": 13996,
2993                "tool_uses": 9,
2994                "total_tokens": 38779
2995            },
2996            "uuid": "85a39f5a-e4d4-47f7-9a6d-1125f1a8035f"
2997        }"#;
2998
2999        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
3000        if let ClaudeOutput::System(sys) = output {
3001            assert!(sys.is_task_progress());
3002            assert!(!sys.is_task_started());
3003
3004            let progress = sys
3005                .as_task_progress()
3006                .expect("Should parse as task_progress");
3007            assert_eq!(progress.task_id, "a4a7e0906e5fc64cc");
3008            assert_eq!(progress.description, "Reading src/jplephem/chebyshev.rs");
3009            assert_eq!(progress.last_tool_name.as_deref(), Some("Read"));
3010            assert_eq!(progress.usage.duration_ms, 13996);
3011            assert_eq!(progress.usage.tool_uses, 9);
3012            assert_eq!(progress.usage.total_tokens, 38779);
3013        } else {
3014            panic!("Expected System message");
3015        }
3016    }
3017
3018    #[test]
3019    fn test_system_message_task_notification_completed() {
3020        let json = r#"{
3021            "type": "system",
3022            "subtype": "task_notification",
3023            "session_id": "bff4f716-17c1-4255-ab7b-eea9d33824e3",
3024            "task_id": "a0ba761e9dc9c316f",
3025            "tool_use_id": "toolu_01Ho6XVXFLVNjTQ9YqowdBXW",
3026            "status": "completed",
3027            "summary": "Agent \"Write Hipparcos data source doc\" completed",
3028            "output_file": "",
3029            "usage": {
3030                "duration_ms": 172300,
3031                "tool_uses": 11,
3032                "total_tokens": 42005
3033            },
3034            "uuid": "269f49b9-218d-4c8d-9f7e-3a5383a0c5b2"
3035        }"#;
3036
3037        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
3038        if let ClaudeOutput::System(sys) = output {
3039            assert!(sys.is_task_notification());
3040
3041            let notif = sys
3042                .as_task_notification()
3043                .expect("Should parse as task_notification");
3044            assert_eq!(notif.status, super::TaskStatus::Completed);
3045            assert_eq!(
3046                notif.summary,
3047                "Agent \"Write Hipparcos data source doc\" completed"
3048            );
3049            assert_eq!(notif.output_file, Some("".to_string()));
3050            assert_eq!(
3051                notif.tool_use_id,
3052                Some("toolu_01Ho6XVXFLVNjTQ9YqowdBXW".to_string())
3053            );
3054            let usage = notif.usage.expect("Should have usage");
3055            assert_eq!(usage.duration_ms, 172300);
3056            assert_eq!(usage.tool_uses, 11);
3057            assert_eq!(usage.total_tokens, 42005);
3058        } else {
3059            panic!("Expected System message");
3060        }
3061    }
3062
3063    #[test]
3064    fn test_system_message_task_notification_failed_no_usage() {
3065        let json = r#"{
3066            "type": "system",
3067            "subtype": "task_notification",
3068            "session_id": "ea629737-3c36-48a8-a1c4-ad761ad35784",
3069            "task_id": "b98f6a3",
3070            "status": "failed",
3071            "summary": "Background command \"Run FSM calibration\" failed with exit code 1",
3072            "output_file": "/tmp/claude-1000/tasks/b98f6a3.output"
3073        }"#;
3074
3075        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
3076        if let ClaudeOutput::System(sys) = output {
3077            let notif = sys
3078                .as_task_notification()
3079                .expect("Should parse as task_notification");
3080            assert_eq!(notif.status, super::TaskStatus::Failed);
3081            assert!(notif.tool_use_id.is_none());
3082            assert!(notif.usage.is_none());
3083            assert_eq!(
3084                notif.output_file,
3085                Some("/tmp/claude-1000/tasks/b98f6a3.output".to_string())
3086            );
3087        } else {
3088            panic!("Expected System message");
3089        }
3090    }
3091
3092    /// Task system messages survive a `to_value` → `from_value` round-trip
3093    /// with their typed accessors still resolving. Mirrors the proxy/relay
3094    /// path where output is reparsed from a `serde_json::Value` rather than
3095    /// straight from the CLI's stdout, so a silently dropped or renamed field
3096    /// surfaces here instead of as a `None` downstream.
3097    #[test]
3098    fn test_task_messages_roundtrip_through_value() {
3099        let cases = [
3100            r#"{"type":"system","subtype":"task_started","session_id":"s1",
3101                "task_id":"t1","task_type":"local_bash","tool_use_id":"tu1",
3102                "description":"Sleep 3s","uuid":"u1"}"#,
3103            r#"{"type":"system","subtype":"task_progress","session_id":"s1",
3104                "task_id":"t1","tool_use_id":"tu1","description":"Running ls",
3105                "last_tool_name":"Bash",
3106                "usage":{"duration_ms":100,"tool_uses":1,"total_tokens":500},
3107                "uuid":"u2"}"#,
3108            r#"{"type":"system","subtype":"task_notification","session_id":"s1",
3109                "task_id":"t1","tool_use_id":"tu1","status":"completed",
3110                "summary":"done","output_file":"",
3111                "usage":{"duration_ms":100,"tool_uses":1,"total_tokens":500},
3112                "uuid":"u3"}"#,
3113        ];
3114
3115        for json in cases {
3116            let output: ClaudeOutput = serde_json::from_str(json).unwrap();
3117            let value = serde_json::to_value(&output).unwrap();
3118            let reparsed: ClaudeOutput = serde_json::from_value(value).unwrap();
3119
3120            let ClaudeOutput::System(sys) = reparsed else {
3121                panic!("Expected System variant after round-trip");
3122            };
3123
3124            match sys.subtype {
3125                super::SystemSubtype::TaskStarted => {
3126                    assert!(
3127                        sys.as_task_started().is_some(),
3128                        "as_task_started failed after round-trip"
3129                    );
3130                }
3131                super::SystemSubtype::TaskProgress => {
3132                    assert!(
3133                        sys.as_task_progress().is_some(),
3134                        "as_task_progress failed after round-trip"
3135                    );
3136                }
3137                super::SystemSubtype::TaskNotification => {
3138                    assert!(
3139                        sys.as_task_notification().is_some(),
3140                        "as_task_notification failed after round-trip"
3141                    );
3142                }
3143                other => panic!("unexpected subtype after round-trip: {other:?}"),
3144            }
3145        }
3146    }
3147
3148    #[test]
3149    fn test_system_message_compact_boundary() {
3150        let json = r#"{
3151            "type": "system",
3152            "subtype": "compact_boundary",
3153            "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
3154            "compact_metadata": {
3155                "pre_tokens": 155285,
3156                "trigger": "auto"
3157            },
3158            "uuid": "a67780d5-74cb-48b1-9137-7a6e7cee45d7"
3159        }"#;
3160
3161        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
3162        if let ClaudeOutput::System(sys) = output {
3163            assert!(sys.is_compact_boundary());
3164            assert!(!sys.is_init());
3165            assert!(!sys.is_status());
3166
3167            let compact = sys
3168                .as_compact_boundary()
3169                .expect("Should parse as compact_boundary");
3170            assert_eq!(compact.session_id, "879c1a88-3756-4092-aa95-0020c4ed9692");
3171            assert_eq!(compact.compact_metadata.pre_tokens, 155285);
3172            assert_eq!(
3173                compact.compact_metadata.trigger,
3174                super::CompactionTrigger::Auto
3175            );
3176            // Per-compaction stats are optional and absent here.
3177            assert!(compact.summary.is_none());
3178            assert!(compact.leaf_message_count.is_none());
3179            assert!(compact.duration_ms.is_none());
3180        } else {
3181            panic!("Expected System message");
3182        }
3183    }
3184
3185    #[test]
3186    fn test_compact_boundary_with_summary_stats() {
3187        // Canonical keys.
3188        let json = r#"{
3189            "type": "system",
3190            "subtype": "compact_boundary",
3191            "session_id": "s1",
3192            "compact_metadata": { "pre_tokens": 1000, "trigger": "manual" },
3193            "summary": "Summarized the earlier exploration.",
3194            "leaf_message_count": 42,
3195            "duration_ms": 1234,
3196            "uuid": "u1"
3197        }"#;
3198        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
3199        let ClaudeOutput::System(sys) = output else {
3200            panic!("Expected System message");
3201        };
3202        let compact = sys.as_compact_boundary().expect("compact_boundary");
3203        assert_eq!(
3204            compact.summary.as_deref(),
3205            Some("Summarized the earlier exploration.")
3206        );
3207        assert_eq!(compact.leaf_message_count, Some(42));
3208        assert_eq!(compact.duration_ms, Some(1234));
3209
3210        // Alternate wire keys (`content` for summary, `message_count` for count)
3211        // deserialize into the same fields.
3212        let json_alt = r#"{
3213            "type": "system",
3214            "subtype": "compact_boundary",
3215            "session_id": "s2",
3216            "compact_metadata": { "pre_tokens": 2000, "trigger": "auto" },
3217            "content": "alt-key summary",
3218            "message_count": 7
3219        }"#;
3220        let output: ClaudeOutput = serde_json::from_str(json_alt).unwrap();
3221        let ClaudeOutput::System(sys) = output else {
3222            panic!("Expected System message");
3223        };
3224        let compact = sys.as_compact_boundary().expect("compact_boundary");
3225        assert_eq!(compact.summary.as_deref(), Some("alt-key summary"));
3226        assert_eq!(compact.leaf_message_count, Some(7));
3227    }
3228
3229    #[test]
3230    fn test_init_message_with_new_fields() {
3231        let json = r#"{
3232            "type": "system",
3233            "subtype": "init",
3234            "session_id": "test-session",
3235            "cwd": "/home/user",
3236            "model": "claude-opus-4-7",
3237            "tools": ["Bash"],
3238            "mcp_servers": [],
3239            "permissionMode": "default",
3240            "apiKeySource": "none",
3241            "uuid": "44841a0d-182d-493a-86b5-79800d3d9665",
3242            "memory_paths": {"auto": "/home/user/.claude/projects/memory/"},
3243            "fast_mode_state": "off",
3244            "plugins": [{"name": "lsp", "path": "/plugins/lsp", "source": "lsp@official"}],
3245            "claude_code_version": "2.1.117"
3246        }"#;
3247
3248        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
3249        if let ClaudeOutput::System(sys) = output {
3250            let init = sys.as_init().expect("Should parse as init");
3251            assert_eq!(
3252                init.uuid.as_deref(),
3253                Some("44841a0d-182d-493a-86b5-79800d3d9665")
3254            );
3255            assert!(init.memory_paths.is_some());
3256            assert_eq!(init.fast_mode_state.as_deref(), Some("off"));
3257            assert_eq!(init.plugins[0].source.as_deref(), Some("lsp@official"));
3258            assert_eq!(init.claude_code_version.as_deref(), Some("2.1.117"));
3259        } else {
3260            panic!("Expected System message");
3261        }
3262    }
3263
3264    #[test]
3265    fn test_assistant_message_with_new_fields() {
3266        let json = r#"{
3267            "type": "assistant",
3268            "message": {
3269                "id": "msg_1",
3270                "type": "message",
3271                "role": "assistant",
3272                "model": "claude-opus-4-7",
3273                "content": [{"type": "text", "text": "Hello"}],
3274                "stop_reason": "end_turn",
3275                "stop_details": null,
3276                "context_management": null,
3277                "usage": {
3278                    "input_tokens": 100,
3279                    "output_tokens": 10,
3280                    "cache_creation_input_tokens": 50,
3281                    "cache_read_input_tokens": 0,
3282                    "service_tier": "standard",
3283                    "inference_geo": "not_available"
3284                }
3285            },
3286            "session_id": "abc",
3287            "uuid": "msg-uuid-123"
3288        }"#;
3289
3290        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
3291        if let ClaudeOutput::Assistant(asst) = output {
3292            assert_eq!(asst.message.stop_details, None);
3293            assert_eq!(asst.message.context_management, None);
3294            let usage = asst.message.usage.unwrap();
3295            assert_eq!(usage.inference_geo.as_deref(), Some("not_available"));
3296        } else {
3297            panic!("Expected Assistant message");
3298        }
3299    }
3300
3301    #[test]
3302    fn test_user_message_with_new_fields() {
3303        let json = r#"{
3304            "type": "user",
3305            "message": {
3306                "role": "user",
3307                "content": [{"type": "text", "text": "Hello"}]
3308            },
3309            "session_id": "9abbc466-dad0-4b8e-b6b0-cad5eb7a16b9",
3310            "parent_tool_use_id": "toolu_123",
3311            "uuid": "user-msg-456"
3312        }"#;
3313
3314        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
3315        if let ClaudeOutput::User(user) = output {
3316            assert_eq!(user.parent_tool_use_id.as_deref(), Some("toolu_123"));
3317            assert_eq!(user.uuid.as_deref(), Some("user-msg-456"));
3318        } else {
3319            panic!("Expected User message");
3320        }
3321    }
3322
3323    /// Real wire payload captured from the CLI after answering an
3324    /// AskUserQuestion via the permission control protocol. The top-level
3325    /// `tool_use_result` and `timestamp` fields must round-trip without loss —
3326    /// proxies using this crate to relay messages to a viewer rely on those
3327    /// fields being preserved (the viewer reads `tool_use_result.answers`).
3328    #[test]
3329    fn test_user_message_preserves_tool_use_result_and_timestamp() {
3330        let json = r#"{
3331            "type":"user",
3332            "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"}]},
3333            "parent_tool_use_id":null,
3334            "session_id":"622ae0c3-3d50-4fa7-9ee0-69d691238c6d",
3335            "uuid":"8ef6e997-a849-4d15-bed3-2837c3d3f4cd",
3336            "timestamp":"2026-05-12T23:12:04.121Z",
3337            "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"}}
3338        }"#;
3339
3340        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
3341        let user = match output {
3342            ClaudeOutput::User(u) => u,
3343            other => panic!("Expected User message, got {:?}", other.message_type()),
3344        };
3345
3346        assert_eq!(user.timestamp.as_deref(), Some("2026-05-12T23:12:04.121Z"));
3347        let raw = user
3348            .tool_use_result
3349            .as_ref()
3350            .expect("tool_use_result must be captured");
3351        assert_eq!(raw["answers"]["Color"], "Blue");
3352        assert_eq!(raw["questions"][0]["header"], "Color");
3353
3354        // Round-trip: re-serialize and confirm tool_use_result + timestamp
3355        // survive — the bug we're guarding against is that the proxy silently
3356        // drops these fields when relaying user messages.
3357        let reser: serde_json::Value = serde_json::to_value(&user).unwrap();
3358        assert_eq!(reser["timestamp"], "2026-05-12T23:12:04.121Z");
3359        assert_eq!(reser["tool_use_result"]["answers"]["Color"], "Blue");
3360        assert_eq!(
3361            reser["tool_use_result"]["questions"][0]["question"],
3362            "Which color do you prefer?"
3363        );
3364
3365        // Typed accessor: AskUserQuestionInput has the same shape as the
3366        // AskUserQuestion tool_use_result.
3367        let typed: crate::AskUserQuestionInput = user
3368            .tool_use_result_as::<crate::AskUserQuestionInput>()
3369            .expect("tool_use_result present")
3370            .expect("AskUserQuestionInput parses");
3371        assert_eq!(typed.questions.len(), 1);
3372        assert_eq!(typed.questions[0].header, "Color");
3373        let answers = typed.answers.expect("answers populated");
3374        assert_eq!(answers.get("Color").map(String::as_str), Some("Blue"));
3375    }
3376
3377    /// User messages without `tool_use_result` / `timestamp` must still
3378    /// deserialize fine and serialize back without spuriously emitting nulls.
3379    #[test]
3380    fn test_user_message_without_tool_use_result_omits_field() {
3381        let json = r#"{
3382            "type":"user",
3383            "message":{"role":"user","content":[{"type":"text","text":"hello"}]},
3384            "session_id":"622ae0c3-3d50-4fa7-9ee0-69d691238c6d"
3385        }"#;
3386
3387        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
3388        let user = match output {
3389            ClaudeOutput::User(u) => u,
3390            _ => panic!("Expected User message"),
3391        };
3392        assert!(user.tool_use_result.is_none());
3393        assert!(user.timestamp.is_none());
3394
3395        let reser = serde_json::to_value(&user).unwrap();
3396        assert!(reser.get("tool_use_result").is_none());
3397        assert!(reser.get("timestamp").is_none());
3398    }
3399
3400    /// A `Task` tool result must expose subagent token / timing / tool-use
3401    /// accounting through the typed [`UserMessage::subagent_result`] accessor,
3402    /// including the nested per-model `usage` breakdown and `toolStats`.
3403    #[test]
3404    fn test_subagent_result_exposes_token_accounting() {
3405        let json = r#"{
3406            "type":"user",
3407            "message":{"role":"user","content":[{"tool_use_id":"toolu_01","type":"tool_result","content":[{"type":"text","text":"21"}]}]},
3408            "session_id":"d3fc5942-75e5-4aa1-a87d-b9484a176541",
3409            "tool_use_result":{
3410                "status":"completed",
3411                "prompt":"Count the .rs files.",
3412                "agentId":"ac4f0276e9d4b6232",
3413                "agentType":"Explore",
3414                "content":[{"type":"text","text":"21"}],
3415                "resolvedModel":"claude-haiku-4-5-20251001",
3416                "totalDurationMs":6869,
3417                "totalTokens":7834,
3418                "totalToolUseCount":1,
3419                "usage":{"input_tokens":6,"cache_creation_input_tokens":125,"cache_read_input_tokens":7699,"output_tokens":4,"service_tier":"standard"},
3420                "toolStats":{"readCount":0,"searchCount":0,"bashCount":1,"editFileCount":0,"linesAdded":0,"linesRemoved":0,"otherToolCount":0}
3421            }
3422        }"#;
3423
3424        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
3425        let user = match output {
3426            ClaudeOutput::User(u) => u,
3427            _ => panic!("Expected User message"),
3428        };
3429
3430        let result = user.subagent_result().expect("subagent result parses");
3431        assert_eq!(result.agent_type.as_deref(), Some("Explore"));
3432        assert_eq!(
3433            result.resolved_model.as_deref(),
3434            Some("claude-haiku-4-5-20251001")
3435        );
3436        assert_eq!(result.total_tokens, Some(7834));
3437        assert_eq!(result.total_duration_ms, Some(6869));
3438        assert_eq!(result.total_tool_use_count, Some(1));
3439
3440        let usage = result.usage.expect("nested usage present");
3441        assert_eq!(usage.input_tokens, 6);
3442        assert_eq!(usage.cache_read_input_tokens, 7699);
3443
3444        let stats = result.tool_stats.expect("toolStats present");
3445        assert_eq!(stats.bash_count, 1);
3446    }
3447
3448    /// `tool_use_result` shapes that aren't subagent runs (e.g. AskUserQuestion)
3449    /// parse leniently into the all-`Option` [`SubagentResult`] with empty
3450    /// accounting rather than failing, so callers can probe without panicking.
3451    #[test]
3452    fn test_subagent_result_absent_for_non_task_result() {
3453        let json = r#"{
3454            "type":"user",
3455            "message":{"role":"user","content":[{"type":"text","text":"hi"}]},
3456            "session_id":"622ae0c3-3d50-4fa7-9ee0-69d691238c6d",
3457            "tool_use_result":{"questions":[],"answers":{"Color":"Blue"}}
3458        }"#;
3459
3460        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
3461        let user = match output {
3462            ClaudeOutput::User(u) => u,
3463            _ => panic!("Expected User message"),
3464        };
3465
3466        let result = user.subagent_result().expect("lenient parse");
3467        assert_eq!(result.total_tokens, None);
3468        assert_eq!(result.agent_type, None);
3469    }
3470
3471    #[test]
3472    fn test_init_fast_mode_reason_and_mcp_server_errors_fully_wrapped() {
3473        use serde_json::Value;
3474
3475        let raw: Value = serde_json::from_str(
3476            r#"{
3477            "type":"system","subtype":"init","session_id":"s1","uuid":"u1",
3478            "fast_mode_state":"off",
3479            "fast_mode_disabled_reason":"not_first_party",
3480            "mcp_server_errors":[{"name":"broken","type":"invalid_config","message":"url entry with no type"}]
3481        }"#,
3482        )
3483        .unwrap();
3484        crate::io::assert_fully_wrapped(&raw);
3485
3486        let output: ClaudeOutput = serde_json::from_value(raw).unwrap();
3487        let ClaudeOutput::System(sys) = output else {
3488            panic!("expected System");
3489        };
3490        let init = sys.as_init().expect("parses as init");
3491        assert_eq!(
3492            init.fast_mode_disabled_reason,
3493            Some(crate::FastModeDisabledReason::NotFirstParty)
3494        );
3495        let errs = init.mcp_server_errors.unwrap();
3496        assert_eq!(errs.len(), 1);
3497        assert_eq!(errs[0].name, "broken");
3498        assert_eq!(errs[0].error_type, "invalid_config");
3499    }
3500
3501    #[test]
3502    fn test_code_change_published_fully_wrapped() {
3503        use super::{KnownSystemEvent, SystemSubtype};
3504        use serde_json::Value;
3505
3506        let raw: Value = serde_json::from_str(
3507            r#"{
3508            "type":"system","subtype":"code_change_published",
3509            "provider":"github","url":"https://github.com/owner/repo/pull/42",
3510            "repo":"owner/repo","identifier":"42",
3511            "uuid":"u1","session_id":"s1"
3512        }"#,
3513        )
3514        .unwrap();
3515        crate::io::assert_fully_wrapped(&raw);
3516
3517        let output: ClaudeOutput = serde_json::from_value(raw).unwrap();
3518        let ClaudeOutput::System(sys) = output else {
3519            panic!("expected System");
3520        };
3521        assert_eq!(sys.subtype, SystemSubtype::CodeChangePublished);
3522        let Some(KnownSystemEvent::CodeChangePublished(msg)) = sys.as_known_system_event() else {
3523            panic!("expected CodeChangePublished event");
3524        };
3525        assert_eq!(msg.provider, "github");
3526        assert_eq!(msg.repo, "owner/repo");
3527        assert_eq!(msg.identifier, "42");
3528
3529        assert!(sys.is_code_change_published());
3530        assert!(!sys.is_vcs_state_changed());
3531        let direct = sys.as_code_change_published().expect("direct accessor");
3532        assert_eq!(direct.url, "https://github.com/owner/repo/pull/42");
3533        assert!(sys.as_vcs_state_changed().is_none());
3534    }
3535
3536    #[test]
3537    fn test_feedback_draft_queued_fully_wrapped() {
3538        use super::{KnownSystemEvent, SystemSubtype};
3539        use serde_json::Value;
3540
3541        let raw: Value = serde_json::from_str(
3542            r#"{
3543            "type":"system","subtype":"feedback_draft_queued",
3544            "draft_id":"draft-1","draft_type":"bug_report",
3545            "title":"Tool output was truncated",
3546            "details_preview":"The last command omitted its final lines",
3547            "uuid":"u1","session_id":"s1","future_field":"preserved"
3548        }"#,
3549        )
3550        .unwrap();
3551        crate::io::assert_fully_wrapped(&raw);
3552
3553        let output: ClaudeOutput = serde_json::from_value(raw).unwrap();
3554        let ClaudeOutput::System(sys) = output else {
3555            panic!("expected System");
3556        };
3557        assert_eq!(sys.subtype, SystemSubtype::FeedbackDraftQueued);
3558        assert!(sys.is_feedback_draft_queued());
3559        assert!(!sys.is_vcs_state_changed());
3560
3561        let direct = sys
3562            .as_feedback_draft_queued()
3563            .expect("direct typed accessor");
3564        assert_eq!(direct.draft_id, "draft-1");
3565        assert_eq!(direct.draft_type, "bug_report");
3566        assert_eq!(direct.extra["future_field"], "preserved");
3567
3568        let Some(KnownSystemEvent::FeedbackDraftQueued(known)) = sys.as_known_system_event() else {
3569            panic!("expected FeedbackDraftQueued event");
3570        };
3571        assert_eq!(known.title, "Tool output was truncated");
3572        assert_eq!(
3573            sys.typed_value().expect("typed value")["future_field"],
3574            "preserved"
3575        );
3576    }
3577
3578    #[test]
3579    fn test_cloud_session_delta_fully_wrapped() {
3580        use super::{KnownSystemEvent, SystemSubtype};
3581        use serde_json::Value;
3582
3583        let raw: Value = serde_json::from_str(
3584            r#"{
3585            "type":"system","subtype":"cloud_session_delta",
3586            "seq":3,"changed":["serving","connection"],
3587            "cloud_session":{"id":"session_abc","view_url":"https://example.invalid/s/abc",
3588                "serving":{"state":"on"},"connection":{"state":"live"}},
3589            "uuid":"u1","session_id":"s1","future_field":"preserved"
3590        }"#,
3591        )
3592        .unwrap();
3593        crate::io::assert_fully_wrapped(&raw);
3594
3595        let output: ClaudeOutput = serde_json::from_value(raw).unwrap();
3596        let ClaudeOutput::System(sys) = output else {
3597            panic!("expected System");
3598        };
3599        assert_eq!(sys.subtype, SystemSubtype::CloudSessionDelta);
3600        assert!(sys.is_cloud_session_delta());
3601        assert!(!sys.is_feedback_draft_queued());
3602
3603        let direct = sys.as_cloud_session_delta().expect("direct typed accessor");
3604        assert_eq!(direct.seq, 3);
3605        assert_eq!(direct.changed, vec!["serving", "connection"]);
3606        assert_eq!(direct.cloud_session["id"], "session_abc");
3607        assert_eq!(direct.extra["future_field"], "preserved");
3608
3609        let Some(KnownSystemEvent::CloudSessionDelta(known)) = sys.as_known_system_event() else {
3610            panic!("expected CloudSessionDelta event");
3611        };
3612        assert_eq!(known.session_id, "s1");
3613        assert_eq!(
3614            sys.typed_value().expect("typed value")["future_field"],
3615            "preserved"
3616        );
3617    }
3618
3619    #[test]
3620    fn test_vcs_state_changed_fully_wrapped() {
3621        use super::{KnownSystemEvent, VcsMutationKind};
3622        use serde_json::Value;
3623
3624        for kind in ["commit", "push", "merge", "rebase"] {
3625            let raw: Value = serde_json::from_str(&format!(
3626                r#"{{"type":"system","subtype":"vcs_state_changed","kind":"{}","cwd":"/repo","uuid":"u1","session_id":"s1"}}"#,
3627                kind
3628            ))
3629            .unwrap();
3630            crate::io::assert_fully_wrapped(&raw);
3631
3632            let output: ClaudeOutput = serde_json::from_value(raw).unwrap();
3633            let ClaudeOutput::System(sys) = output else {
3634                panic!("expected System");
3635            };
3636            let Some(KnownSystemEvent::VcsStateChanged(msg)) = sys.as_known_system_event() else {
3637                panic!("expected VcsStateChanged event");
3638            };
3639            assert_eq!(msg.kind.as_str(), kind);
3640            assert!(!matches!(msg.kind, VcsMutationKind::Unknown(_)));
3641        }
3642
3643        // Unknown kinds are valid per the wire contract.
3644        let raw: Value = serde_json::from_str(
3645            r#"{"type":"system","subtype":"vcs_state_changed","kind":"tag","cwd":"/repo","uuid":"u2","session_id":"s2"}"#,
3646        )
3647        .unwrap();
3648        crate::io::assert_fully_wrapped(&raw);
3649        let output: ClaudeOutput = serde_json::from_value(raw).unwrap();
3650        let ClaudeOutput::System(sys) = output else {
3651            panic!("expected System");
3652        };
3653        let Some(KnownSystemEvent::VcsStateChanged(msg)) = sys.as_known_system_event() else {
3654            panic!("expected VcsStateChanged event");
3655        };
3656        assert_eq!(msg.kind, VcsMutationKind::Unknown("tag".to_string()));
3657
3658        assert!(sys.is_vcs_state_changed());
3659        let direct = sys.as_vcs_state_changed().expect("direct accessor");
3660        assert_eq!(direct.cwd, "/repo");
3661        assert!(sys.as_code_change_published().is_none());
3662    }
3663
3664    #[test]
3665    fn test_assistant_aborted_and_resume_flags_roundtrip() {
3666        let json = r#"{
3667            "type":"assistant",
3668            "message":{"id":"msg_1","role":"assistant","model":"claude-3","content":[{"type":"text","text":"partial"}]},
3669            "session_id":"s1",
3670            "aborted":true,
3671            "resumed_from_incomplete_thinking":true
3672        }"#;
3673        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
3674        let ClaudeOutput::Assistant(msg) = &output else {
3675            panic!("expected Assistant");
3676        };
3677        assert_eq!(msg.aborted, Some(true));
3678        assert_eq!(msg.resumed_from_incomplete_thinking, Some(true));
3679        let reserialized = serde_json::to_string(&output).unwrap();
3680        assert!(reserialized.contains("\"aborted\":true"));
3681        assert!(reserialized.contains("\"resumed_from_incomplete_thinking\":true"));
3682
3683        // Absent flags stay absent on the wire.
3684        let json = r#"{
3685            "type":"assistant",
3686            "message":{"id":"msg_2","role":"assistant","model":"claude-3","content":[]},
3687            "session_id":"s2"
3688        }"#;
3689        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
3690        let reserialized = serde_json::to_string(&output).unwrap();
3691        assert!(!reserialized.contains("aborted"));
3692        assert!(!reserialized.contains("resumed_from_incomplete_thinking"));
3693    }
3694
3695    #[test]
3696    fn test_user_tool_result_meta_roundtrip() {
3697        let json = r#"{
3698            "type":"user",
3699            "message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"denied"}]},
3700            "session_id":"622ae0c3-3d50-4fa7-9ee0-69d691238c6d",
3701            "tool_result_meta":[
3702                {"id":"toolu_1","non_execution_kind":"user-rejected","user_feedback":"use the staging db"},
3703                {"id":"toolu_2","non_execution_kind":"permission-rule"}
3704            ]
3705        }"#;
3706        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
3707        let ClaudeOutput::User(user) = &output else {
3708            panic!("expected User");
3709        };
3710        let meta = user.tool_result_meta.as_ref().unwrap();
3711        assert_eq!(meta.len(), 2);
3712        assert_eq!(meta[0].non_execution_kind, "user-rejected");
3713        assert_eq!(meta[0].user_feedback.as_deref(), Some("use the staging db"));
3714        assert_eq!(meta[1].user_feedback, None);
3715
3716        let reserialized = serde_json::to_string(&output).unwrap();
3717        assert!(reserialized.contains("\"non_execution_kind\":\"user-rejected\""));
3718        assert!(!reserialized.contains("\"user_feedback\":null"));
3719    }
3720
3721    /// CLI 2.1.222 added `scope` to `system/model_refusal_fallback`:
3722    /// "session" (main-thread swap, also the meaning when absent on older
3723    /// CLIs) vs "local" (subagent/side-question fallback only).
3724    #[test]
3725    fn model_refusal_fallback_scope_roundtrips_and_defaults() {
3726        use super::{ModelRefusalFallbackMessage, RefusalFallbackScope};
3727        let with_scope = serde_json::json!({
3728            "trigger": "refusal",
3729            "direction": "retry",
3730            "scope": "local",
3731            "original_model": "claude-fable-5",
3732            "fallback_model": "claude-opus-5",
3733            "request_id": null,
3734            "content": "Refused; retried on fallback model.",
3735            "uuid": "u1",
3736            "session_id": "s1"
3737        });
3738        let msg: ModelRefusalFallbackMessage = serde_json::from_value(with_scope.clone()).unwrap();
3739        assert_eq!(msg.scope, Some(RefusalFallbackScope::Local));
3740        assert_eq!(serde_json::to_value(&msg).unwrap(), with_scope);
3741
3742        // Older CLIs omit scope — absent, not null, and treated as session
3743        // by consumers per the wire docs.
3744        let mut without = with_scope.clone();
3745        without.as_object_mut().unwrap().remove("scope");
3746        let msg: ModelRefusalFallbackMessage = serde_json::from_value(without.clone()).unwrap();
3747        assert_eq!(msg.scope, None);
3748        assert_eq!(serde_json::to_value(&msg).unwrap(), without);
3749
3750        // Open enum: unknown scopes pass through verbatim.
3751        assert_eq!(
3752            RefusalFallbackScope::from("workspace").as_str(),
3753            "workspace"
3754        );
3755    }
3756}