Skip to main content

claude_codes/io/
message_types.rs

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