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