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    /// Unique identifier for this message
668    #[serde(skip_serializing_if = "Option::is_none")]
669    pub uuid: Option<String>,
670}
671
672/// Metadata about context compaction
673#[derive(Debug, Clone, Serialize, Deserialize)]
674pub struct CompactMetadata {
675    /// Number of tokens before compaction
676    pub pre_tokens: u64,
677    /// What triggered the compaction
678    pub trigger: CompactionTrigger,
679}
680
681// ---------------------------------------------------------------------------
682// Task system message types (task_started, task_progress, task_notification)
683// ---------------------------------------------------------------------------
684
685/// Cumulative usage statistics for a background task.
686#[derive(Debug, Clone, Serialize, Deserialize)]
687pub struct TaskUsage {
688    /// Wall-clock milliseconds since the task started.
689    pub duration_ms: u64,
690    /// Total number of tool calls made so far.
691    pub tool_uses: u64,
692    /// Total tokens consumed so far.
693    pub total_tokens: u64,
694}
695
696/// The kind of background task.
697#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
698#[serde(rename_all = "snake_case")]
699pub enum TaskType {
700    /// A sub-agent task (e.g., Explore, Plan).
701    LocalAgent,
702    /// A background bash command.
703    LocalBash,
704}
705
706/// Completion status of a background task.
707#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
708#[serde(rename_all = "snake_case")]
709pub enum TaskStatus {
710    Completed,
711    Failed,
712}
713
714/// `task_started` system message — emitted once when a background task begins.
715#[derive(Debug, Clone, Serialize, Deserialize)]
716pub struct TaskStartedMessage {
717    pub session_id: String,
718    pub task_id: String,
719    pub task_type: TaskType,
720    pub tool_use_id: String,
721    pub description: String,
722    pub uuid: String,
723}
724
725/// `task_progress` system message — emitted periodically as a background
726/// agent task executes tools. Not emitted for `local_bash` tasks.
727#[derive(Debug, Clone, Serialize, Deserialize)]
728pub struct TaskProgressMessage {
729    pub session_id: String,
730    pub task_id: String,
731    pub tool_use_id: String,
732    pub description: String,
733    pub last_tool_name: String,
734    pub usage: TaskUsage,
735    pub uuid: String,
736}
737
738/// `task_notification` system message — emitted once when a background
739/// task completes or fails.
740#[derive(Debug, Clone, Serialize, Deserialize)]
741pub struct TaskNotificationMessage {
742    pub session_id: String,
743    pub task_id: String,
744    pub status: TaskStatus,
745    pub summary: String,
746    pub output_file: Option<String>,
747    #[serde(skip_serializing_if = "Option::is_none")]
748    pub tool_use_id: Option<String>,
749    #[serde(skip_serializing_if = "Option::is_none")]
750    pub usage: Option<TaskUsage>,
751    #[serde(skip_serializing_if = "Option::is_none")]
752    pub uuid: Option<String>,
753}
754
755/// Assistant message
756#[derive(Debug, Clone, Serialize, Deserialize)]
757pub struct AssistantMessage {
758    pub message: AssistantMessageContent,
759    pub session_id: String,
760    #[serde(skip_serializing_if = "Option::is_none")]
761    pub uuid: Option<String>,
762    #[serde(skip_serializing_if = "Option::is_none")]
763    pub parent_tool_use_id: Option<String>,
764}
765
766/// Nested message content for assistant messages
767#[derive(Debug, Clone, Serialize, Deserialize)]
768pub struct AssistantMessageContent {
769    pub id: String,
770    pub role: MessageRole,
771    pub model: String,
772    pub content: Vec<ContentBlock>,
773    #[serde(skip_serializing_if = "Option::is_none")]
774    pub stop_reason: Option<StopReason>,
775    #[serde(skip_serializing_if = "Option::is_none")]
776    pub stop_sequence: Option<String>,
777    #[serde(skip_serializing_if = "Option::is_none")]
778    pub usage: Option<AssistantUsage>,
779    /// Details about why generation stopped
780    #[serde(skip_serializing_if = "Option::is_none")]
781    pub stop_details: Option<Value>,
782    /// Context management metadata
783    #[serde(skip_serializing_if = "Option::is_none")]
784    pub context_management: Option<Value>,
785}
786
787/// Usage information for assistant messages
788#[derive(Debug, Clone, Serialize, Deserialize)]
789pub struct AssistantUsage {
790    /// Number of input tokens
791    #[serde(default)]
792    pub input_tokens: u32,
793
794    /// Number of output tokens
795    #[serde(default)]
796    pub output_tokens: u32,
797
798    /// Tokens used to create cache
799    #[serde(default)]
800    pub cache_creation_input_tokens: u32,
801
802    /// Tokens read from cache
803    #[serde(default)]
804    pub cache_read_input_tokens: u32,
805
806    /// Service tier used (e.g., "standard")
807    #[serde(skip_serializing_if = "Option::is_none")]
808    pub service_tier: Option<String>,
809
810    /// Detailed cache creation breakdown
811    #[serde(skip_serializing_if = "Option::is_none")]
812    pub cache_creation: Option<CacheCreationDetails>,
813
814    /// Inference geography (e.g., "not_available")
815    #[serde(skip_serializing_if = "Option::is_none")]
816    pub inference_geo: Option<String>,
817}
818
819/// Detailed cache creation information
820#[derive(Debug, Clone, Serialize, Deserialize)]
821pub struct CacheCreationDetails {
822    /// Ephemeral 1-hour input tokens
823    #[serde(default)]
824    pub ephemeral_1h_input_tokens: u32,
825
826    /// Ephemeral 5-minute input tokens
827    #[serde(default)]
828    pub ephemeral_5m_input_tokens: u32,
829}
830
831#[cfg(test)]
832mod tests {
833    use crate::io::ClaudeOutput;
834
835    #[test]
836    fn test_system_message_init() {
837        let json = r#"{
838            "type": "system",
839            "subtype": "init",
840            "session_id": "test-session-123",
841            "cwd": "/home/user/project",
842            "model": "claude-sonnet-4",
843            "tools": ["Bash", "Read", "Write"],
844            "mcp_servers": [],
845            "slash_commands": ["compact", "cost", "review"],
846            "agents": ["Bash", "Explore", "Plan"],
847            "plugins": [{"name": "rust-analyzer-lsp", "path": "/home/user/.claude/plugins/rust-analyzer-lsp/1.0.0"}],
848            "skills": [],
849            "claude_code_version": "2.1.15",
850            "apiKeySource": "none",
851            "output_style": "default",
852            "permissionMode": "default"
853        }"#;
854
855        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
856        if let ClaudeOutput::System(sys) = output {
857            assert!(sys.is_init());
858            assert!(!sys.is_status());
859            assert!(!sys.is_compact_boundary());
860
861            let init = sys.as_init().expect("Should parse as init");
862            assert_eq!(init.session_id, "test-session-123");
863            assert_eq!(init.cwd, Some("/home/user/project".to_string()));
864            assert_eq!(init.model, Some("claude-sonnet-4".to_string()));
865            assert_eq!(init.tools, vec!["Bash", "Read", "Write"]);
866            assert_eq!(init.slash_commands, vec!["compact", "cost", "review"]);
867            assert_eq!(init.agents, vec!["Bash", "Explore", "Plan"]);
868            assert_eq!(init.plugins.len(), 1);
869            assert_eq!(init.plugins[0].name, "rust-analyzer-lsp");
870            assert_eq!(init.claude_code_version, Some("2.1.15".to_string()));
871            assert_eq!(init.api_key_source, Some(super::ApiKeySource::None));
872            assert_eq!(init.output_style, Some(super::OutputStyle::Default));
873            assert_eq!(
874                init.permission_mode,
875                Some(super::InitPermissionMode::Default)
876            );
877        } else {
878            panic!("Expected System message");
879        }
880    }
881
882    #[test]
883    fn test_system_message_init_from_real_capture() {
884        let json = include_str!("../../test_cases/tool_use_captures/tool_msg_0.json");
885        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
886        if let ClaudeOutput::System(sys) = output {
887            let init = sys.as_init().expect("Should parse real init capture");
888            assert_eq!(init.slash_commands.len(), 8);
889            assert!(init.slash_commands.contains(&"compact".to_string()));
890            assert!(init.slash_commands.contains(&"review".to_string()));
891            assert_eq!(init.agents.len(), 5);
892            assert!(init.agents.contains(&"Bash".to_string()));
893            assert!(init.agents.contains(&"Explore".to_string()));
894            assert_eq!(init.plugins.len(), 1);
895            assert_eq!(init.plugins[0].name, "rust-analyzer-lsp");
896            assert_eq!(init.claude_code_version, Some("2.1.15".to_string()));
897        } else {
898            panic!("Expected System message");
899        }
900    }
901
902    #[test]
903    fn test_system_message_status() {
904        let json = r#"{
905            "type": "system",
906            "subtype": "status",
907            "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
908            "status": "compacting",
909            "uuid": "32eb9f9d-5ef7-47ff-8fce-bbe22fe7ed93"
910        }"#;
911
912        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
913        if let ClaudeOutput::System(sys) = output {
914            assert!(sys.is_status());
915            assert!(!sys.is_init());
916
917            let status = sys.as_status().expect("Should parse as status");
918            assert_eq!(status.session_id, "879c1a88-3756-4092-aa95-0020c4ed9692");
919            assert_eq!(status.status, Some(super::StatusMessageStatus::Compacting));
920            assert_eq!(
921                status.uuid,
922                Some("32eb9f9d-5ef7-47ff-8fce-bbe22fe7ed93".to_string())
923            );
924        } else {
925            panic!("Expected System message");
926        }
927    }
928
929    #[test]
930    fn test_system_message_status_null() {
931        let json = r#"{
932            "type": "system",
933            "subtype": "status",
934            "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
935            "status": null,
936            "uuid": "92d9637e-d00e-418e-acd2-a504e3861c6a"
937        }"#;
938
939        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
940        if let ClaudeOutput::System(sys) = output {
941            let status = sys.as_status().expect("Should parse as status");
942            assert_eq!(status.status, None);
943        } else {
944            panic!("Expected System message");
945        }
946    }
947
948    #[test]
949    fn test_system_message_task_started() {
950        let json = r#"{
951            "type": "system",
952            "subtype": "task_started",
953            "session_id": "9abbc466-dad0-4b8e-b6b0-cad5eb7a16b9",
954            "task_id": "b6daf3f",
955            "task_type": "local_bash",
956            "tool_use_id": "toolu_011rfSTFumpJZdCCfzeD7jaS",
957            "description": "Wait for CI on PR #12",
958            "uuid": "c4243261-c128-4747-b8c3-5e1c7c10eeb8"
959        }"#;
960
961        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
962        if let ClaudeOutput::System(sys) = output {
963            assert!(sys.is_task_started());
964            assert!(!sys.is_task_progress());
965            assert!(!sys.is_task_notification());
966
967            let task = sys.as_task_started().expect("Should parse as task_started");
968            assert_eq!(task.session_id, "9abbc466-dad0-4b8e-b6b0-cad5eb7a16b9");
969            assert_eq!(task.task_id, "b6daf3f");
970            assert_eq!(task.task_type, super::TaskType::LocalBash);
971            assert_eq!(task.tool_use_id, "toolu_011rfSTFumpJZdCCfzeD7jaS");
972            assert_eq!(task.description, "Wait for CI on PR #12");
973        } else {
974            panic!("Expected System message");
975        }
976    }
977
978    #[test]
979    fn test_system_message_task_started_agent() {
980        let json = r#"{
981            "type": "system",
982            "subtype": "task_started",
983            "session_id": "bff4f716-17c1-4255-ab7b-eea9d33824e3",
984            "task_id": "a4a7e0906e5fc64cc",
985            "task_type": "local_agent",
986            "tool_use_id": "toolu_01SFz9FwZ1cYgCSy8vRM7wep",
987            "description": "Explore Scene/ArrayScene duplication",
988            "uuid": "85a39f5a-e4d4-47f7-9a6d-1125f1a8035f"
989        }"#;
990
991        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
992        if let ClaudeOutput::System(sys) = output {
993            let task = sys.as_task_started().expect("Should parse as task_started");
994            assert_eq!(task.task_type, super::TaskType::LocalAgent);
995            assert_eq!(task.task_id, "a4a7e0906e5fc64cc");
996        } else {
997            panic!("Expected System message");
998        }
999    }
1000
1001    #[test]
1002    fn test_system_message_task_progress() {
1003        let json = r#"{
1004            "type": "system",
1005            "subtype": "task_progress",
1006            "session_id": "bff4f716-17c1-4255-ab7b-eea9d33824e3",
1007            "task_id": "a4a7e0906e5fc64cc",
1008            "tool_use_id": "toolu_01SFz9FwZ1cYgCSy8vRM7wep",
1009            "description": "Reading src/jplephem/chebyshev.rs",
1010            "last_tool_name": "Read",
1011            "usage": {
1012                "duration_ms": 13996,
1013                "tool_uses": 9,
1014                "total_tokens": 38779
1015            },
1016            "uuid": "85a39f5a-e4d4-47f7-9a6d-1125f1a8035f"
1017        }"#;
1018
1019        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1020        if let ClaudeOutput::System(sys) = output {
1021            assert!(sys.is_task_progress());
1022            assert!(!sys.is_task_started());
1023
1024            let progress = sys
1025                .as_task_progress()
1026                .expect("Should parse as task_progress");
1027            assert_eq!(progress.task_id, "a4a7e0906e5fc64cc");
1028            assert_eq!(progress.description, "Reading src/jplephem/chebyshev.rs");
1029            assert_eq!(progress.last_tool_name, "Read");
1030            assert_eq!(progress.usage.duration_ms, 13996);
1031            assert_eq!(progress.usage.tool_uses, 9);
1032            assert_eq!(progress.usage.total_tokens, 38779);
1033        } else {
1034            panic!("Expected System message");
1035        }
1036    }
1037
1038    #[test]
1039    fn test_system_message_task_notification_completed() {
1040        let json = r#"{
1041            "type": "system",
1042            "subtype": "task_notification",
1043            "session_id": "bff4f716-17c1-4255-ab7b-eea9d33824e3",
1044            "task_id": "a0ba761e9dc9c316f",
1045            "tool_use_id": "toolu_01Ho6XVXFLVNjTQ9YqowdBXW",
1046            "status": "completed",
1047            "summary": "Agent \"Write Hipparcos data source doc\" completed",
1048            "output_file": "",
1049            "usage": {
1050                "duration_ms": 172300,
1051                "tool_uses": 11,
1052                "total_tokens": 42005
1053            },
1054            "uuid": "269f49b9-218d-4c8d-9f7e-3a5383a0c5b2"
1055        }"#;
1056
1057        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1058        if let ClaudeOutput::System(sys) = output {
1059            assert!(sys.is_task_notification());
1060
1061            let notif = sys
1062                .as_task_notification()
1063                .expect("Should parse as task_notification");
1064            assert_eq!(notif.status, super::TaskStatus::Completed);
1065            assert_eq!(
1066                notif.summary,
1067                "Agent \"Write Hipparcos data source doc\" completed"
1068            );
1069            assert_eq!(notif.output_file, Some("".to_string()));
1070            assert_eq!(
1071                notif.tool_use_id,
1072                Some("toolu_01Ho6XVXFLVNjTQ9YqowdBXW".to_string())
1073            );
1074            let usage = notif.usage.expect("Should have usage");
1075            assert_eq!(usage.duration_ms, 172300);
1076            assert_eq!(usage.tool_uses, 11);
1077            assert_eq!(usage.total_tokens, 42005);
1078        } else {
1079            panic!("Expected System message");
1080        }
1081    }
1082
1083    #[test]
1084    fn test_system_message_task_notification_failed_no_usage() {
1085        let json = r#"{
1086            "type": "system",
1087            "subtype": "task_notification",
1088            "session_id": "ea629737-3c36-48a8-a1c4-ad761ad35784",
1089            "task_id": "b98f6a3",
1090            "status": "failed",
1091            "summary": "Background command \"Run FSM calibration\" failed with exit code 1",
1092            "output_file": "/tmp/claude-1000/tasks/b98f6a3.output"
1093        }"#;
1094
1095        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1096        if let ClaudeOutput::System(sys) = output {
1097            let notif = sys
1098                .as_task_notification()
1099                .expect("Should parse as task_notification");
1100            assert_eq!(notif.status, super::TaskStatus::Failed);
1101            assert!(notif.tool_use_id.is_none());
1102            assert!(notif.usage.is_none());
1103            assert_eq!(
1104                notif.output_file,
1105                Some("/tmp/claude-1000/tasks/b98f6a3.output".to_string())
1106            );
1107        } else {
1108            panic!("Expected System message");
1109        }
1110    }
1111
1112    #[test]
1113    fn test_system_message_compact_boundary() {
1114        let json = r#"{
1115            "type": "system",
1116            "subtype": "compact_boundary",
1117            "session_id": "879c1a88-3756-4092-aa95-0020c4ed9692",
1118            "compact_metadata": {
1119                "pre_tokens": 155285,
1120                "trigger": "auto"
1121            },
1122            "uuid": "a67780d5-74cb-48b1-9137-7a6e7cee45d7"
1123        }"#;
1124
1125        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1126        if let ClaudeOutput::System(sys) = output {
1127            assert!(sys.is_compact_boundary());
1128            assert!(!sys.is_init());
1129            assert!(!sys.is_status());
1130
1131            let compact = sys
1132                .as_compact_boundary()
1133                .expect("Should parse as compact_boundary");
1134            assert_eq!(compact.session_id, "879c1a88-3756-4092-aa95-0020c4ed9692");
1135            assert_eq!(compact.compact_metadata.pre_tokens, 155285);
1136            assert_eq!(
1137                compact.compact_metadata.trigger,
1138                super::CompactionTrigger::Auto
1139            );
1140        } else {
1141            panic!("Expected System message");
1142        }
1143    }
1144
1145    #[test]
1146    fn test_init_message_with_new_fields() {
1147        let json = r#"{
1148            "type": "system",
1149            "subtype": "init",
1150            "session_id": "test-session",
1151            "cwd": "/home/user",
1152            "model": "claude-opus-4-7",
1153            "tools": ["Bash"],
1154            "mcp_servers": [],
1155            "permissionMode": "default",
1156            "apiKeySource": "none",
1157            "uuid": "44841a0d-182d-493a-86b5-79800d3d9665",
1158            "memory_paths": {"auto": "/home/user/.claude/projects/memory/"},
1159            "fast_mode_state": "off",
1160            "plugins": [{"name": "lsp", "path": "/plugins/lsp", "source": "lsp@official"}],
1161            "claude_code_version": "2.1.117"
1162        }"#;
1163
1164        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1165        if let ClaudeOutput::System(sys) = output {
1166            let init = sys.as_init().expect("Should parse as init");
1167            assert_eq!(
1168                init.uuid.as_deref(),
1169                Some("44841a0d-182d-493a-86b5-79800d3d9665")
1170            );
1171            assert!(init.memory_paths.is_some());
1172            assert_eq!(init.fast_mode_state.as_deref(), Some("off"));
1173            assert_eq!(init.plugins[0].source.as_deref(), Some("lsp@official"));
1174            assert_eq!(init.claude_code_version.as_deref(), Some("2.1.117"));
1175        } else {
1176            panic!("Expected System message");
1177        }
1178    }
1179
1180    #[test]
1181    fn test_assistant_message_with_new_fields() {
1182        let json = r#"{
1183            "type": "assistant",
1184            "message": {
1185                "id": "msg_1",
1186                "type": "message",
1187                "role": "assistant",
1188                "model": "claude-opus-4-7",
1189                "content": [{"type": "text", "text": "Hello"}],
1190                "stop_reason": "end_turn",
1191                "stop_details": null,
1192                "context_management": null,
1193                "usage": {
1194                    "input_tokens": 100,
1195                    "output_tokens": 10,
1196                    "cache_creation_input_tokens": 50,
1197                    "cache_read_input_tokens": 0,
1198                    "service_tier": "standard",
1199                    "inference_geo": "not_available"
1200                }
1201            },
1202            "session_id": "abc",
1203            "uuid": "msg-uuid-123"
1204        }"#;
1205
1206        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1207        if let ClaudeOutput::Assistant(asst) = output {
1208            assert_eq!(asst.message.stop_details, None);
1209            assert_eq!(asst.message.context_management, None);
1210            let usage = asst.message.usage.unwrap();
1211            assert_eq!(usage.inference_geo.as_deref(), Some("not_available"));
1212        } else {
1213            panic!("Expected Assistant message");
1214        }
1215    }
1216
1217    #[test]
1218    fn test_user_message_with_new_fields() {
1219        let json = r#"{
1220            "type": "user",
1221            "message": {
1222                "role": "user",
1223                "content": [{"type": "text", "text": "Hello"}]
1224            },
1225            "session_id": "9abbc466-dad0-4b8e-b6b0-cad5eb7a16b9",
1226            "parent_tool_use_id": "toolu_123",
1227            "uuid": "user-msg-456"
1228        }"#;
1229
1230        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1231        if let ClaudeOutput::User(user) = output {
1232            assert_eq!(user.parent_tool_use_id.as_deref(), Some("toolu_123"));
1233            assert_eq!(user.uuid.as_deref(), Some("user-msg-456"));
1234        } else {
1235            panic!("Expected User message");
1236        }
1237    }
1238
1239    /// Real wire payload captured from the CLI after answering an
1240    /// AskUserQuestion via the permission control protocol. The top-level
1241    /// `tool_use_result` and `timestamp` fields must round-trip without loss —
1242    /// proxies using this crate to relay messages to a viewer rely on those
1243    /// fields being preserved (the viewer reads `tool_use_result.answers`).
1244    #[test]
1245    fn test_user_message_preserves_tool_use_result_and_timestamp() {
1246        let json = r#"{
1247            "type":"user",
1248            "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"}]},
1249            "parent_tool_use_id":null,
1250            "session_id":"622ae0c3-3d50-4fa7-9ee0-69d691238c6d",
1251            "uuid":"8ef6e997-a849-4d15-bed3-2837c3d3f4cd",
1252            "timestamp":"2026-05-12T23:12:04.121Z",
1253            "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"}}
1254        }"#;
1255
1256        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1257        let user = match output {
1258            ClaudeOutput::User(u) => u,
1259            other => panic!("Expected User message, got {:?}", other.message_type()),
1260        };
1261
1262        assert_eq!(user.timestamp.as_deref(), Some("2026-05-12T23:12:04.121Z"));
1263        let raw = user
1264            .tool_use_result
1265            .as_ref()
1266            .expect("tool_use_result must be captured");
1267        assert_eq!(raw["answers"]["Color"], "Blue");
1268        assert_eq!(raw["questions"][0]["header"], "Color");
1269
1270        // Round-trip: re-serialize and confirm tool_use_result + timestamp
1271        // survive — the bug we're guarding against is that the proxy silently
1272        // drops these fields when relaying user messages.
1273        let reser: serde_json::Value = serde_json::to_value(&user).unwrap();
1274        assert_eq!(reser["timestamp"], "2026-05-12T23:12:04.121Z");
1275        assert_eq!(reser["tool_use_result"]["answers"]["Color"], "Blue");
1276        assert_eq!(
1277            reser["tool_use_result"]["questions"][0]["question"],
1278            "Which color do you prefer?"
1279        );
1280
1281        // Typed accessor: AskUserQuestionInput has the same shape as the
1282        // AskUserQuestion tool_use_result.
1283        let typed: crate::AskUserQuestionInput = user
1284            .tool_use_result_as::<crate::AskUserQuestionInput>()
1285            .expect("tool_use_result present")
1286            .expect("AskUserQuestionInput parses");
1287        assert_eq!(typed.questions.len(), 1);
1288        assert_eq!(typed.questions[0].header, "Color");
1289        let answers = typed.answers.expect("answers populated");
1290        assert_eq!(answers.get("Color").map(String::as_str), Some("Blue"));
1291    }
1292
1293    /// User messages without `tool_use_result` / `timestamp` must still
1294    /// deserialize fine and serialize back without spuriously emitting nulls.
1295    #[test]
1296    fn test_user_message_without_tool_use_result_omits_field() {
1297        let json = r#"{
1298            "type":"user",
1299            "message":{"role":"user","content":[{"type":"text","text":"hello"}]},
1300            "session_id":"622ae0c3-3d50-4fa7-9ee0-69d691238c6d"
1301        }"#;
1302
1303        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1304        let user = match output {
1305            ClaudeOutput::User(u) => u,
1306            _ => panic!("Expected User message"),
1307        };
1308        assert!(user.tool_use_result.is_none());
1309        assert!(user.timestamp.is_none());
1310
1311        let reser = serde_json::to_value(&user).unwrap();
1312        assert!(reser.get("tool_use_result").is_none());
1313        assert!(reser.get("timestamp").is_none());
1314    }
1315}