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