Skip to main content

claude_codes/io/
claude_output.rs

1use serde::{Deserialize, Deserializer, Serialize};
2use serde_json::{Map, Value};
3
4use super::content_blocks::{ContentBlock, ToolUseBlock};
5use super::control::{ControlRequest, ControlResponse};
6use super::errors::{AnthropicError, ParseError};
7use super::message_types::{AssistantMessage, SystemMessage, UserMessage};
8use super::rate_limit::RateLimitEvent;
9use super::result::ResultMessage;
10
11/// Top-level enum for all possible Claude output messages
12#[derive(Debug, Clone, Serialize)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum ClaudeOutput {
15    /// System initialization message
16    System(SystemMessage),
17
18    /// User message echoed back
19    User(UserMessage),
20
21    /// Assistant response
22    Assistant(AssistantMessage),
23
24    /// Result message (completion of a query)
25    Result(ResultMessage),
26
27    /// Claude Code internal workflow-journal result event.
28    #[serde(rename = "result")]
29    TranscriptResult(TranscriptMessage),
30
31    /// Control request from CLI (tool permissions, hooks, etc.)
32    ControlRequest(ControlRequest),
33
34    /// Control response from CLI (ack for initialization, etc.)
35    ControlResponse(ControlResponse),
36
37    /// API error from Anthropic (500, 529 overloaded, etc.)
38    Error(AnthropicError),
39
40    /// Rate limit status event
41    RateLimitEvent(RateLimitEvent),
42
43    /// Raw API stream event emitted with `--include-partial-messages`.
44    StreamEvent(StreamEventMessage),
45
46    /// Progress update for a running tool.
47    ToolProgress(ToolProgressMessage),
48
49    /// Fate of a queued command (slash command or queued user prompt).
50    CommandLifecycle(CommandLifecycleMessage),
51
52    /// Authentication status update.
53    AuthStatus(AuthStatusMessage),
54
55    /// Summary of preceding tool uses.
56    ToolUseSummary(ToolUseSummaryMessage),
57
58    /// Predicted next user prompt.
59    PromptSuggestion(PromptSuggestionMessage),
60
61    /// Conversation reset notification.
62    ConversationReset(ConversationResetMessage),
63
64    /// Claude Code internal transcript progress event.
65    Progress(TranscriptMessage),
66
67    /// Claude Code internal queue event.
68    #[serde(rename = "queue-operation")]
69    QueueOperation(TranscriptMessage),
70
71    /// Claude Code internal PR link metadata event.
72    #[serde(rename = "pr-link")]
73    PrLink(TranscriptMessage),
74
75    /// Claude Code internal file history snapshot event.
76    #[serde(rename = "file-history-snapshot")]
77    FileHistorySnapshot(TranscriptMessage),
78
79    /// Claude Code internal session summary event.
80    Summary(TranscriptMessage),
81
82    /// Claude Code internal mode metadata event.
83    Mode(TranscriptMessage),
84
85    /// Claude Code internal permission mode metadata event.
86    #[serde(rename = "permission-mode")]
87    PermissionMode(TranscriptMessage),
88
89    /// Claude Code internal attachment metadata event.
90    Attachment(TranscriptMessage),
91
92    /// Claude Code internal AI-generated title event.
93    #[serde(rename = "ai-title")]
94    AiTitle(TranscriptMessage),
95
96    /// Claude Code internal last prompt pointer event.
97    #[serde(rename = "last-prompt")]
98    LastPrompt(TranscriptMessage),
99
100    /// Claude Code internal session started event.
101    Started(TranscriptMessage),
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct StreamEventMessage {
106    pub event: Value,
107    pub parent_tool_use_id: Option<String>,
108    pub uuid: String,
109    pub session_id: String,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub ttft_ms: Option<u64>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct ToolProgressMessage {
116    pub tool_use_id: String,
117    pub tool_name: String,
118    pub parent_tool_use_id: Option<String>,
119    pub elapsed_time_seconds: f64,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub task_id: Option<String>,
122    pub uuid: String,
123    pub session_id: String,
124    /// True when this event was emitted only to keep the stream alive, not
125    /// because the tool reported progress.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub heartbeat: Option<bool>,
128    /// Subagent type for progress from a `Task` tool's subagent.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub subagent_type: Option<String>,
131    /// Present while a subagent API call is being retried after an error.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub subagent_retry: Option<SubagentRetry>,
134}
135
136/// Retry state carried on a [`ToolProgressMessage`] while a subagent API
137/// call is retried after an error.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct SubagentRetry {
140    pub agent_id: String,
141    pub attempt: u64,
142    pub max_retries: u64,
143    pub retry_delay_ms: u64,
144    pub error_status: Option<u16>,
145    pub error_category: String,
146}
147
148/// `command_lifecycle` message — the fate of a queued command (slash command
149/// or queued user prompt): `queued` when the inbound message enters the
150/// command queue, `started` when it drains into a turn, then exactly one
151/// terminal state (`completed`, `cancelled`, or `discarded`). Commands
152/// enqueued without a client-supplied uuid emit no lifecycle events. Not a
153/// strict pairing — a terminal state may arrive for a `command_uuid` that
154/// never emitted `started`, and internally-enqueued commands skip `queued`.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct CommandLifecycleMessage {
157    /// The queued command's uuid — the client-supplied uuid on the inbound
158    /// message (distinct from the universal per-frame `uuid`).
159    pub command_uuid: String,
160    pub state: CommandLifecycleState,
161    pub uuid: String,
162    pub session_id: String,
163}
164
165/// Lifecycle state carried by a [`CommandLifecycleMessage`].
166#[derive(Debug, Clone, PartialEq, Eq, Hash)]
167pub enum CommandLifecycleState {
168    /// The inbound message entered the command queue.
169    Queued,
170    /// The command drained into a turn.
171    Started,
172    /// The turn that consumed the command ended cleanly.
173    Completed,
174    /// Removed by cancel, caught before dispatch, or consumed into a turn
175    /// that was aborted or died on a hard failure.
176    Cancelled,
177    /// The session ended with the command still queued.
178    Discarded,
179    /// A state not yet known to this version of the crate.
180    Unknown(String),
181}
182
183impl CommandLifecycleState {
184    pub fn as_str(&self) -> &str {
185        match self {
186            Self::Queued => "queued",
187            Self::Started => "started",
188            Self::Completed => "completed",
189            Self::Cancelled => "cancelled",
190            Self::Discarded => "discarded",
191            Self::Unknown(s) => s.as_str(),
192        }
193    }
194}
195
196impl std::fmt::Display for CommandLifecycleState {
197    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        f.write_str(self.as_str())
199    }
200}
201
202impl From<&str> for CommandLifecycleState {
203    fn from(s: &str) -> Self {
204        match s {
205            "queued" => Self::Queued,
206            "started" => Self::Started,
207            "completed" => Self::Completed,
208            "cancelled" => Self::Cancelled,
209            "discarded" => Self::Discarded,
210            other => Self::Unknown(other.to_string()),
211        }
212    }
213}
214
215impl Serialize for CommandLifecycleState {
216    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
217        serializer.serialize_str(self.as_str())
218    }
219}
220
221impl<'de> Deserialize<'de> for CommandLifecycleState {
222    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
223        let s = String::deserialize(deserializer)?;
224        Ok(Self::from(s.as_str()))
225    }
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct AuthStatusMessage {
230    #[serde(rename = "isAuthenticating")]
231    pub is_authenticating: bool,
232    pub output: Vec<String>,
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub error: Option<String>,
235    pub uuid: String,
236    pub session_id: String,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct ToolUseSummaryMessage {
241    pub summary: String,
242    pub preceding_tool_use_ids: Vec<String>,
243    pub uuid: String,
244    pub session_id: String,
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub timestamp: Option<String>,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct PromptSuggestionMessage {
251    pub suggestion: String,
252    pub uuid: String,
253    pub session_id: String,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct ConversationResetMessage {
258    pub new_conversation_id: String,
259    pub uuid: String,
260    pub session_id: String,
261}
262
263/// Raw preserved record for Claude Code transcript-only message types.
264///
265/// These events are emitted in `~/.claude/projects/**/*.jsonl`, not in the
266/// public `--output-format stream-json` protocol. Keeping them typed at the
267/// top level lets corpus tests parse real transcript files without losing the
268/// original payload.
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct TranscriptMessage {
271    #[serde(flatten)]
272    pub data: Map<String, Value>,
273}
274
275impl ClaudeOutput {
276    /// Get the message type as a string
277    pub fn message_type(&self) -> String {
278        match self {
279            ClaudeOutput::System(_) => "system".to_string(),
280            ClaudeOutput::User(_) => "user".to_string(),
281            ClaudeOutput::Assistant(_) => "assistant".to_string(),
282            ClaudeOutput::Result(_) => "result".to_string(),
283            ClaudeOutput::TranscriptResult(_) => "result".to_string(),
284            ClaudeOutput::ControlRequest(_) => "control_request".to_string(),
285            ClaudeOutput::ControlResponse(_) => "control_response".to_string(),
286            ClaudeOutput::Error(_) => "error".to_string(),
287            ClaudeOutput::RateLimitEvent(_) => "rate_limit_event".to_string(),
288            ClaudeOutput::StreamEvent(_) => "stream_event".to_string(),
289            ClaudeOutput::ToolProgress(_) => "tool_progress".to_string(),
290            ClaudeOutput::CommandLifecycle(_) => "command_lifecycle".to_string(),
291            ClaudeOutput::AuthStatus(_) => "auth_status".to_string(),
292            ClaudeOutput::ToolUseSummary(_) => "tool_use_summary".to_string(),
293            ClaudeOutput::PromptSuggestion(_) => "prompt_suggestion".to_string(),
294            ClaudeOutput::ConversationReset(_) => "conversation_reset".to_string(),
295            ClaudeOutput::Progress(_) => "progress".to_string(),
296            ClaudeOutput::QueueOperation(_) => "queue-operation".to_string(),
297            ClaudeOutput::PrLink(_) => "pr-link".to_string(),
298            ClaudeOutput::FileHistorySnapshot(_) => "file-history-snapshot".to_string(),
299            ClaudeOutput::Summary(_) => "summary".to_string(),
300            ClaudeOutput::Mode(_) => "mode".to_string(),
301            ClaudeOutput::PermissionMode(_) => "permission-mode".to_string(),
302            ClaudeOutput::Attachment(_) => "attachment".to_string(),
303            ClaudeOutput::AiTitle(_) => "ai-title".to_string(),
304            ClaudeOutput::LastPrompt(_) => "last-prompt".to_string(),
305            ClaudeOutput::Started(_) => "started".to_string(),
306        }
307    }
308
309    /// Check if this is a control request (tool permission request)
310    pub fn is_control_request(&self) -> bool {
311        matches!(self, ClaudeOutput::ControlRequest(_))
312    }
313
314    /// Check if this is a control response
315    pub fn is_control_response(&self) -> bool {
316        matches!(self, ClaudeOutput::ControlResponse(_))
317    }
318
319    /// Check if this is an Anthropic API error
320    pub fn is_api_error(&self) -> bool {
321        matches!(self, ClaudeOutput::Error(_))
322    }
323
324    /// Get the control request if this is one
325    pub fn as_control_request(&self) -> Option<&ControlRequest> {
326        match self {
327            ClaudeOutput::ControlRequest(req) => Some(req),
328            _ => None,
329        }
330    }
331
332    /// Get the Anthropic error if this is one
333    ///
334    /// # Example
335    /// ```
336    /// use claude_codes::ClaudeOutput;
337    ///
338    /// let json = r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#;
339    /// let output: ClaudeOutput = serde_json::from_str(json).unwrap();
340    ///
341    /// if let Some(err) = output.as_anthropic_error() {
342    ///     if err.is_overloaded() {
343    ///         println!("API is overloaded, retrying...");
344    ///     }
345    /// }
346    /// ```
347    pub fn as_anthropic_error(&self) -> Option<&AnthropicError> {
348        match self {
349            ClaudeOutput::Error(err) => Some(err),
350            _ => None,
351        }
352    }
353
354    /// Check if this is a rate limit event
355    pub fn is_rate_limit_event(&self) -> bool {
356        matches!(self, ClaudeOutput::RateLimitEvent(_))
357    }
358
359    /// Get the rate limit event if this is one
360    pub fn as_rate_limit_event(&self) -> Option<&RateLimitEvent> {
361        match self {
362            ClaudeOutput::RateLimitEvent(evt) => Some(evt),
363            _ => None,
364        }
365    }
366
367    /// Check if this is a result with error
368    pub fn is_error(&self) -> bool {
369        matches!(self, ClaudeOutput::Result(r) if r.is_error)
370    }
371
372    /// Check if this is an assistant message
373    pub fn is_assistant_message(&self) -> bool {
374        matches!(self, ClaudeOutput::Assistant(_))
375    }
376
377    /// Check if this is a system message
378    pub fn is_system_message(&self) -> bool {
379        matches!(self, ClaudeOutput::System(_))
380    }
381
382    /// Check if this is a system init message
383    ///
384    /// # Example
385    /// ```
386    /// use claude_codes::ClaudeOutput;
387    ///
388    /// let json = r#"{"type":"system","subtype":"init","session_id":"abc"}"#;
389    /// let output: ClaudeOutput = serde_json::from_str(json).unwrap();
390    /// assert!(output.is_system_init());
391    /// ```
392    pub fn is_system_init(&self) -> bool {
393        matches!(self, ClaudeOutput::System(sys) if sys.is_init())
394    }
395
396    /// Get the session ID from any message type that has one.
397    ///
398    /// Returns the session ID from System, Assistant, or Result messages.
399    /// Returns `None` for User, ControlRequest, and ControlResponse messages.
400    ///
401    /// # Example
402    /// ```
403    /// use claude_codes::ClaudeOutput;
404    ///
405    /// let json = r#"{"type":"result","subtype":"success","is_error":false,
406    ///     "duration_ms":100,"duration_api_ms":200,"num_turns":1,
407    ///     "session_id":"my-session","total_cost_usd":0.01}"#;
408    /// let output: ClaudeOutput = serde_json::from_str(json).unwrap();
409    /// assert_eq!(output.session_id(), Some("my-session"));
410    /// ```
411    pub fn session_id(&self) -> Option<&str> {
412        match self {
413            ClaudeOutput::System(sys) => sys
414                .data
415                .get("session_id")
416                .or_else(|| sys.data.get("sessionId"))
417                .and_then(|v| v.as_str()),
418            ClaudeOutput::Assistant(ass) => Some(&ass.session_id),
419            ClaudeOutput::Result(res) => Some(&res.session_id),
420            ClaudeOutput::TranscriptResult(msg) => msg
421                .data
422                .get("session_id")
423                .or_else(|| msg.data.get("sessionId"))
424                .and_then(|v| v.as_str()),
425            ClaudeOutput::User(_) => None,
426            ClaudeOutput::ControlRequest(_) => None,
427            ClaudeOutput::ControlResponse(_) => None,
428            ClaudeOutput::Error(_) => None,
429            ClaudeOutput::RateLimitEvent(evt) => Some(&evt.session_id),
430            ClaudeOutput::StreamEvent(msg) => Some(&msg.session_id),
431            ClaudeOutput::ToolProgress(msg) => Some(&msg.session_id),
432            ClaudeOutput::CommandLifecycle(msg) => Some(&msg.session_id),
433            ClaudeOutput::AuthStatus(msg) => Some(&msg.session_id),
434            ClaudeOutput::ToolUseSummary(msg) => Some(&msg.session_id),
435            ClaudeOutput::PromptSuggestion(msg) => Some(&msg.session_id),
436            ClaudeOutput::ConversationReset(msg) => Some(&msg.session_id),
437            ClaudeOutput::Progress(msg)
438            | ClaudeOutput::QueueOperation(msg)
439            | ClaudeOutput::PrLink(msg)
440            | ClaudeOutput::FileHistorySnapshot(msg)
441            | ClaudeOutput::Summary(msg)
442            | ClaudeOutput::Mode(msg)
443            | ClaudeOutput::PermissionMode(msg)
444            | ClaudeOutput::Attachment(msg)
445            | ClaudeOutput::AiTitle(msg)
446            | ClaudeOutput::LastPrompt(msg)
447            | ClaudeOutput::Started(msg) => msg
448                .data
449                .get("session_id")
450                .or_else(|| msg.data.get("sessionId"))
451                .and_then(|v| v.as_str()),
452        }
453    }
454
455    /// Get a specific tool use by name from an assistant message.
456    ///
457    /// Returns the first `ToolUseBlock` with the given name, or `None` if this
458    /// is not an assistant message or doesn't contain the specified tool.
459    ///
460    /// # Example
461    /// ```
462    /// use claude_codes::ClaudeOutput;
463    ///
464    /// let json = r#"{"type":"assistant","message":{"id":"msg_1","role":"assistant",
465    ///     "model":"claude-3","content":[{"type":"tool_use","id":"tu_1",
466    ///     "name":"Bash","input":{"command":"ls"}}]},"session_id":"abc"}"#;
467    /// let output: ClaudeOutput = serde_json::from_str(json).unwrap();
468    ///
469    /// if let Some(bash) = output.as_tool_use("Bash") {
470    ///     assert_eq!(bash.name, "Bash");
471    /// }
472    /// ```
473    pub fn as_tool_use(&self, tool_name: &str) -> Option<&ToolUseBlock> {
474        match self {
475            ClaudeOutput::Assistant(ass) => {
476                ass.message.content.iter().find_map(|block| match block {
477                    ContentBlock::ToolUse(tu) if tu.name == tool_name => Some(tu),
478                    _ => None,
479                })
480            }
481            _ => None,
482        }
483    }
484
485    /// Get all tool uses from an assistant message.
486    ///
487    /// Returns an iterator over all `ToolUseBlock`s in the message, or an empty
488    /// iterator if this is not an assistant message.
489    ///
490    /// # Example
491    /// ```
492    /// use claude_codes::ClaudeOutput;
493    ///
494    /// let json = r#"{"type":"assistant","message":{"id":"msg_1","role":"assistant",
495    ///     "model":"claude-3","content":[
496    ///         {"type":"tool_use","id":"tu_1","name":"Read","input":{"file_path":"/tmp/a"}},
497    ///         {"type":"tool_use","id":"tu_2","name":"Write","input":{"file_path":"/tmp/b","content":"x"}}
498    ///     ]},"session_id":"abc"}"#;
499    /// let output: ClaudeOutput = serde_json::from_str(json).unwrap();
500    ///
501    /// let tools: Vec<_> = output.tool_uses().collect();
502    /// assert_eq!(tools.len(), 2);
503    /// ```
504    pub fn tool_uses(&self) -> impl Iterator<Item = &ToolUseBlock> {
505        let content = match self {
506            ClaudeOutput::Assistant(ass) => Some(&ass.message.content),
507            _ => None,
508        };
509
510        content
511            .into_iter()
512            .flat_map(|c| c.iter())
513            .filter_map(|block| match block {
514                ContentBlock::ToolUse(tu) => Some(tu),
515                _ => None,
516            })
517    }
518
519    /// Get text content from an assistant message.
520    ///
521    /// Returns the concatenated text from all text blocks in the message,
522    /// or `None` if this is not an assistant message or has no text content.
523    ///
524    /// # Example
525    /// ```
526    /// use claude_codes::ClaudeOutput;
527    ///
528    /// let json = r#"{"type":"assistant","message":{"id":"msg_1","role":"assistant",
529    ///     "model":"claude-3","content":[{"type":"text","text":"Hello, world!"}]},
530    ///     "session_id":"abc"}"#;
531    /// let output: ClaudeOutput = serde_json::from_str(json).unwrap();
532    /// assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
533    /// ```
534    pub fn text_content(&self) -> Option<String> {
535        match self {
536            ClaudeOutput::Assistant(ass) => {
537                let texts: Vec<&str> = ass
538                    .message
539                    .content
540                    .iter()
541                    .filter_map(|block| match block {
542                        ContentBlock::Text(t) => Some(t.text.as_str()),
543                        _ => None,
544                    })
545                    .collect();
546
547                if texts.is_empty() {
548                    None
549                } else {
550                    Some(texts.join(""))
551                }
552            }
553            _ => None,
554        }
555    }
556
557    /// Get the assistant message if this is one.
558    ///
559    /// # Example
560    /// ```
561    /// use claude_codes::ClaudeOutput;
562    ///
563    /// let json = r#"{"type":"assistant","message":{"id":"msg_1","role":"assistant",
564    ///     "model":"claude-3","content":[]},"session_id":"abc"}"#;
565    /// let output: ClaudeOutput = serde_json::from_str(json).unwrap();
566    ///
567    /// if let Some(assistant) = output.as_assistant() {
568    ///     assert_eq!(assistant.message.model, "claude-3");
569    /// }
570    /// ```
571    pub fn as_assistant(&self) -> Option<&AssistantMessage> {
572        match self {
573            ClaudeOutput::Assistant(ass) => Some(ass),
574            _ => None,
575        }
576    }
577
578    /// Get the result message if this is one.
579    ///
580    /// # Example
581    /// ```
582    /// use claude_codes::ClaudeOutput;
583    ///
584    /// let json = r#"{"type":"result","subtype":"success","is_error":false,
585    ///     "duration_ms":100,"duration_api_ms":200,"num_turns":1,
586    ///     "session_id":"abc","total_cost_usd":0.01}"#;
587    /// let output: ClaudeOutput = serde_json::from_str(json).unwrap();
588    ///
589    /// if let Some(result) = output.as_result() {
590    ///     assert!(!result.is_error);
591    /// }
592    /// ```
593    pub fn as_result(&self) -> Option<&ResultMessage> {
594        match self {
595            ClaudeOutput::Result(res) => Some(res),
596            _ => None,
597        }
598    }
599
600    /// Get the system message if this is one.
601    pub fn as_system(&self) -> Option<&SystemMessage> {
602        match self {
603            ClaudeOutput::System(sys) => Some(sys),
604            _ => None,
605        }
606    }
607
608    /// Parse a JSON string, handling potential ANSI escape codes and other prefixes
609    /// This method will:
610    /// 1. First try to parse as-is
611    /// 2. If that fails, trim until it finds a '{' and try again
612    pub fn parse_json_tolerant(s: &str) -> Result<ClaudeOutput, ParseError> {
613        // First try to parse as-is
614        match Self::parse_json(s) {
615            Ok(output) => Ok(output),
616            Err(first_error) => {
617                // If that fails, look for the first '{' character
618                if let Some(json_start) = s.find('{') {
619                    let trimmed = &s[json_start..];
620                    match Self::parse_json(trimmed) {
621                        Ok(output) => Ok(output),
622                        Err(_) => {
623                            // Return the original error if both attempts fail
624                            Err(first_error)
625                        }
626                    }
627                } else {
628                    Err(first_error)
629                }
630            }
631        }
632    }
633
634    /// Parse a JSON string, returning ParseError with raw JSON if it doesn't match our types
635    pub fn parse_json(s: &str) -> Result<ClaudeOutput, ParseError> {
636        // First try to parse as a Value
637        let value: Value = serde_json::from_str(s).map_err(|e| ParseError {
638            raw_line: s.to_string(),
639            raw_json: None,
640            error_message: format!("Invalid JSON: {}", e),
641        })?;
642
643        // Then try to parse that Value as ClaudeOutput
644        serde_json::from_value::<ClaudeOutput>(value.clone()).map_err(|e| ParseError {
645            raw_line: s.to_string(),
646            raw_json: Some(value),
647            error_message: e.to_string(),
648        })
649    }
650}
651
652impl<'de> Deserialize<'de> for ClaudeOutput {
653    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
654        let value = Value::deserialize(deserializer)?;
655        let message_type = value
656            .get("type")
657            .and_then(|v| v.as_str())
658            .ok_or_else(|| serde::de::Error::missing_field("type"))?;
659        let mut payload = value.clone();
660        if let Some(obj) = payload.as_object_mut() {
661            obj.remove("type");
662        }
663
664        fn parse<T, E>(value: Value) -> Result<T, E>
665        where
666            T: serde::de::DeserializeOwned,
667            E: serde::de::Error,
668        {
669            serde_json::from_value(value).map_err(E::custom)
670        }
671
672        match message_type {
673            "system" => parse(payload).map(Self::System),
674            "user" => parse(payload).map(Self::User),
675            "assistant" => parse(payload).map(Self::Assistant),
676            "result" if value.get("subtype").is_some() => parse(payload).map(Self::Result),
677            "result" => parse(payload).map(Self::TranscriptResult),
678            "control_request" => parse(payload).map(Self::ControlRequest),
679            "control_response" => parse(payload).map(Self::ControlResponse),
680            "error" => parse(payload).map(Self::Error),
681            "rate_limit_event" => parse(payload).map(Self::RateLimitEvent),
682            "stream_event" => parse(payload).map(Self::StreamEvent),
683            "tool_progress" => parse(payload).map(Self::ToolProgress),
684            "command_lifecycle" => parse(payload).map(Self::CommandLifecycle),
685            "auth_status" => parse(payload).map(Self::AuthStatus),
686            "tool_use_summary" => parse(payload).map(Self::ToolUseSummary),
687            "prompt_suggestion" => parse(payload).map(Self::PromptSuggestion),
688            "conversation_reset" => parse(payload).map(Self::ConversationReset),
689            "progress" => parse(payload).map(Self::Progress),
690            "queue-operation" => parse(payload).map(Self::QueueOperation),
691            "pr-link" => parse(payload).map(Self::PrLink),
692            "file-history-snapshot" => parse(payload).map(Self::FileHistorySnapshot),
693            "summary" => parse(payload).map(Self::Summary),
694            "mode" => parse(payload).map(Self::Mode),
695            "permission-mode" => parse(payload).map(Self::PermissionMode),
696            "attachment" => parse(payload).map(Self::Attachment),
697            "ai-title" => parse(payload).map(Self::AiTitle),
698            "last-prompt" => parse(payload).map(Self::LastPrompt),
699            "started" => parse(payload).map(Self::Started),
700            other => Err(serde::de::Error::unknown_variant(
701                other,
702                &[
703                    "system",
704                    "user",
705                    "assistant",
706                    "result",
707                    "control_request",
708                    "control_response",
709                    "error",
710                    "rate_limit_event",
711                    "stream_event",
712                    "tool_progress",
713                    "command_lifecycle",
714                    "auth_status",
715                    "tool_use_summary",
716                    "prompt_suggestion",
717                    "conversation_reset",
718                    "progress",
719                    "queue-operation",
720                    "pr-link",
721                    "file-history-snapshot",
722                    "summary",
723                    "mode",
724                    "permission-mode",
725                    "attachment",
726                    "ai-title",
727                    "last-prompt",
728                    "started",
729                ],
730            )),
731        }
732    }
733}
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738
739    #[test]
740    fn test_deserialize_assistant_message() {
741        let json = r#"{
742            "type": "assistant",
743            "message": {
744                "id": "msg_123",
745                "role": "assistant",
746                "model": "claude-3-sonnet",
747                "content": [{"type": "text", "text": "Hello! How can I help you?"}]
748            },
749            "session_id": "123"
750        }"#;
751
752        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
753        assert!(output.is_assistant_message());
754    }
755
756    #[test]
757    fn test_deserialize_new_top_level_message_types() {
758        let cases = [
759            (
760                r#"{"type":"stream_event","event":{"type":"content_block_delta"},"parent_tool_use_id":null,"uuid":"u1","session_id":"s1","ttft_ms":12}"#,
761                "stream_event",
762            ),
763            (
764                r#"{"type":"tool_progress","tool_use_id":"toolu_1","tool_name":"Bash","parent_tool_use_id":null,"elapsed_time_seconds":1.25,"task_id":"task-1","uuid":"u2","session_id":"s2"}"#,
765                "tool_progress",
766            ),
767            (
768                r#"{"type":"auth_status","isAuthenticating":true,"output":["login"],"uuid":"u3","session_id":"s3"}"#,
769                "auth_status",
770            ),
771            (
772                r#"{"type":"tool_use_summary","summary":"read files","preceding_tool_use_ids":["toolu_1"],"uuid":"u4","session_id":"s4","timestamp":"2026-07-09T17:46:33Z"}"#,
773                "tool_use_summary",
774            ),
775            (
776                r#"{"type":"prompt_suggestion","suggestion":"Run tests","uuid":"u5","session_id":"s5"}"#,
777                "prompt_suggestion",
778            ),
779            (
780                r#"{"type":"conversation_reset","new_conversation_id":"new-session","uuid":"u6","session_id":"s6"}"#,
781                "conversation_reset",
782            ),
783            (
784                r#"{"type":"command_lifecycle","command_uuid":"cmd-1","state":"queued","uuid":"u7","session_id":"s7"}"#,
785                "command_lifecycle",
786            ),
787        ];
788
789        for (json, message_type) in cases {
790            let output: ClaudeOutput = serde_json::from_str(json).unwrap();
791            assert_eq!(output.message_type(), message_type);
792            assert!(output.session_id().is_some());
793        }
794    }
795
796    #[test]
797    fn test_command_lifecycle_states_roundtrip() {
798        for state in ["queued", "started", "completed", "cancelled", "discarded"] {
799            let json = format!(
800                r#"{{"type":"command_lifecycle","command_uuid":"cmd-1","state":"{}","uuid":"u1","session_id":"s1"}}"#,
801                state
802            );
803            let output: ClaudeOutput = serde_json::from_str(&json).unwrap();
804            let ClaudeOutput::CommandLifecycle(msg) = &output else {
805                panic!("expected CommandLifecycle");
806            };
807            assert_eq!(msg.state.as_str(), state);
808            assert!(!matches!(msg.state, CommandLifecycleState::Unknown(_)));
809            assert_eq!(serde_json::to_string(&output).unwrap(), json);
810        }
811
812        // Unknown states survive decode and round-trip verbatim.
813        let json = r#"{"type":"command_lifecycle","command_uuid":"cmd-2","state":"parked","uuid":"u2","session_id":"s2"}"#;
814        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
815        let ClaudeOutput::CommandLifecycle(msg) = &output else {
816            panic!("expected CommandLifecycle");
817        };
818        assert_eq!(
819            msg.state,
820            CommandLifecycleState::Unknown("parked".to_string())
821        );
822        assert_eq!(serde_json::to_string(&output).unwrap(), json);
823    }
824
825    #[test]
826    fn test_tool_progress_heartbeat_and_subagent_retry() {
827        let json = r#"{"type":"tool_progress","tool_use_id":"toolu_1","tool_name":"Task","parent_tool_use_id":null,"elapsed_time_seconds":30,"uuid":"u1","session_id":"s1","heartbeat":true,"subagent_type":"Explore","subagent_retry":{"agent_id":"agent-1","attempt":2,"max_retries":5,"retry_delay_ms":4000,"error_status":529,"error_category":"overloaded"}}"#;
828        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
829        let ClaudeOutput::ToolProgress(msg) = &output else {
830            panic!("expected ToolProgress");
831        };
832        assert_eq!(msg.heartbeat, Some(true));
833        assert_eq!(msg.subagent_type.as_deref(), Some("Explore"));
834        let retry = msg.subagent_retry.as_ref().unwrap();
835        assert_eq!(retry.attempt, 2);
836        assert_eq!(retry.error_status, Some(529));
837        assert_eq!(retry.error_category, "overloaded");
838
839        // Null error_status parses too.
840        let json = r#"{"type":"tool_progress","tool_use_id":"toolu_2","tool_name":"Task","parent_tool_use_id":null,"elapsed_time_seconds":1,"uuid":"u2","session_id":"s2","subagent_retry":{"agent_id":"agent-2","attempt":1,"max_retries":3,"retry_delay_ms":500,"error_status":null,"error_category":"network"}}"#;
841        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
842        let ClaudeOutput::ToolProgress(msg) = &output else {
843            panic!("expected ToolProgress");
844        };
845        assert_eq!(msg.subagent_retry.as_ref().unwrap().error_status, None);
846    }
847
848    #[test]
849    fn test_deserialize_transcript_only_message_types() {
850        let cases = [
851            (
852                r#"{"type":"progress","sessionId":"s1","content":"delta"}"#,
853                "progress",
854                Some("s1"),
855            ),
856            (
857                r#"{"type":"queue-operation","sessionId":"s2","operation":"push"}"#,
858                "queue-operation",
859                Some("s2"),
860            ),
861            (
862                r#"{"type":"pr-link","sessionId":"s3","url":"https://example.invalid/pr"}"#,
863                "pr-link",
864                Some("s3"),
865            ),
866            (
867                r#"{"type":"file-history-snapshot","sessionId":"s4","files":[]}"#,
868                "file-history-snapshot",
869                Some("s4"),
870            ),
871            (r#"{"type":"summary","summary":"Done"}"#, "summary", None),
872            (
873                r#"{"type":"mode","mode":"normal","sessionId":"s5"}"#,
874                "mode",
875                Some("s5"),
876            ),
877            (
878                r#"{"type":"permission-mode","permissionMode":"default","sessionId":"s6"}"#,
879                "permission-mode",
880                Some("s6"),
881            ),
882            (
883                r#"{"type":"attachment","attachment":{"type":"task_reminder"},"sessionId":"s7"}"#,
884                "attachment",
885                Some("s7"),
886            ),
887            (
888                r#"{"type":"ai-title","aiTitle":"Title","sessionId":"s8"}"#,
889                "ai-title",
890                Some("s8"),
891            ),
892            (
893                r#"{"type":"last-prompt","lastPrompt":"prompt","sessionId":"s9"}"#,
894                "last-prompt",
895                Some("s9"),
896            ),
897            (
898                r#"{"type":"started","sessionId":"s10"}"#,
899                "started",
900                Some("s10"),
901            ),
902            (
903                r#"{"type":"result","key":"k","agentId":"a","result":{"ok":true}}"#,
904                "result",
905                None,
906            ),
907        ];
908
909        for (json, message_type, session_id) in cases {
910            let output: ClaudeOutput = serde_json::from_str(json).unwrap();
911            assert_eq!(output.message_type(), message_type);
912            assert_eq!(output.session_id(), session_id);
913        }
914    }
915
916    #[test]
917    fn test_transcript_assistant_accepts_camel_case_session_id() {
918        let json = r#"{
919            "type": "assistant",
920            "sessionId": "camel-session",
921            "message": {
922                "id": "msg_123",
923                "role": "assistant",
924                "model": "claude-3-sonnet",
925                "content": [{"type": "text", "text": "Hello"}]
926            }
927        }"#;
928
929        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
930        assert_eq!(output.session_id(), Some("camel-session"));
931    }
932
933    #[test]
934    fn test_is_system_init() {
935        let init_json = r#"{
936            "type": "system",
937            "subtype": "init",
938            "session_id": "test-session"
939        }"#;
940        let output: ClaudeOutput = serde_json::from_str(init_json).unwrap();
941        assert!(output.is_system_init());
942
943        let status_json = r#"{
944            "type": "system",
945            "subtype": "status",
946            "session_id": "test-session"
947        }"#;
948        let output: ClaudeOutput = serde_json::from_str(status_json).unwrap();
949        assert!(!output.is_system_init());
950    }
951
952    #[test]
953    fn test_session_id() {
954        // Result message
955        let result_json = r#"{
956            "type": "result",
957            "subtype": "success",
958            "is_error": false,
959            "duration_ms": 100,
960            "duration_api_ms": 200,
961            "num_turns": 1,
962            "session_id": "result-session",
963            "total_cost_usd": 0.01
964        }"#;
965        let output: ClaudeOutput = serde_json::from_str(result_json).unwrap();
966        assert_eq!(output.session_id(), Some("result-session"));
967
968        // Assistant message
969        let assistant_json = r#"{
970            "type": "assistant",
971            "message": {
972                "id": "msg_1",
973                "role": "assistant",
974                "model": "claude-3",
975                "content": []
976            },
977            "session_id": "assistant-session"
978        }"#;
979        let output: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
980        assert_eq!(output.session_id(), Some("assistant-session"));
981
982        // System message
983        let system_json = r#"{
984            "type": "system",
985            "subtype": "init",
986            "session_id": "system-session"
987        }"#;
988        let output: ClaudeOutput = serde_json::from_str(system_json).unwrap();
989        assert_eq!(output.session_id(), Some("system-session"));
990    }
991
992    #[test]
993    fn test_as_tool_use() {
994        let json = r#"{
995            "type": "assistant",
996            "message": {
997                "id": "msg_1",
998                "role": "assistant",
999                "model": "claude-3",
1000                "content": [
1001                    {"type": "text", "text": "Let me run that command."},
1002                    {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls -la"}},
1003                    {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/test"}}
1004                ]
1005            },
1006            "session_id": "abc"
1007        }"#;
1008        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1009
1010        // Find Bash tool
1011        let bash = output.as_tool_use("Bash");
1012        assert!(bash.is_some());
1013        assert_eq!(bash.unwrap().id, "tu_1");
1014
1015        // Find Read tool
1016        let read = output.as_tool_use("Read");
1017        assert!(read.is_some());
1018        assert_eq!(read.unwrap().id, "tu_2");
1019
1020        // Non-existent tool
1021        assert!(output.as_tool_use("Write").is_none());
1022
1023        // Not an assistant message
1024        let result_json = r#"{
1025            "type": "result",
1026            "subtype": "success",
1027            "is_error": false,
1028            "duration_ms": 100,
1029            "duration_api_ms": 200,
1030            "num_turns": 1,
1031            "session_id": "abc",
1032            "total_cost_usd": 0.01
1033        }"#;
1034        let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1035        assert!(result.as_tool_use("Bash").is_none());
1036    }
1037
1038    #[test]
1039    fn test_tool_uses() {
1040        let json = r#"{
1041            "type": "assistant",
1042            "message": {
1043                "id": "msg_1",
1044                "role": "assistant",
1045                "model": "claude-3",
1046                "content": [
1047                    {"type": "text", "text": "Running commands..."},
1048                    {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls"}},
1049                    {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/a"}},
1050                    {"type": "tool_use", "id": "tu_3", "name": "Write", "input": {"file_path": "/tmp/b", "content": "x"}}
1051                ]
1052            },
1053            "session_id": "abc"
1054        }"#;
1055        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1056
1057        let tools: Vec<_> = output.tool_uses().collect();
1058        assert_eq!(tools.len(), 3);
1059        assert_eq!(tools[0].name, "Bash");
1060        assert_eq!(tools[1].name, "Read");
1061        assert_eq!(tools[2].name, "Write");
1062    }
1063
1064    #[test]
1065    fn test_text_content() {
1066        // Single text block
1067        let json = r#"{
1068            "type": "assistant",
1069            "message": {
1070                "id": "msg_1",
1071                "role": "assistant",
1072                "model": "claude-3",
1073                "content": [{"type": "text", "text": "Hello, world!"}]
1074            },
1075            "session_id": "abc"
1076        }"#;
1077        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1078        assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
1079
1080        // Multiple text blocks
1081        let json = r#"{
1082            "type": "assistant",
1083            "message": {
1084                "id": "msg_1",
1085                "role": "assistant",
1086                "model": "claude-3",
1087                "content": [
1088                    {"type": "text", "text": "Hello, "},
1089                    {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}},
1090                    {"type": "text", "text": "world!"}
1091                ]
1092            },
1093            "session_id": "abc"
1094        }"#;
1095        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1096        assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
1097
1098        // No text blocks
1099        let json = r#"{
1100            "type": "assistant",
1101            "message": {
1102                "id": "msg_1",
1103                "role": "assistant",
1104                "model": "claude-3",
1105                "content": [{"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}}]
1106            },
1107            "session_id": "abc"
1108        }"#;
1109        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1110        assert_eq!(output.text_content(), None);
1111
1112        // Not an assistant message
1113        let json = r#"{
1114            "type": "result",
1115            "subtype": "success",
1116            "is_error": false,
1117            "duration_ms": 100,
1118            "duration_api_ms": 200,
1119            "num_turns": 1,
1120            "session_id": "abc",
1121            "total_cost_usd": 0.01
1122        }"#;
1123        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1124        assert_eq!(output.text_content(), None);
1125    }
1126
1127    #[test]
1128    fn test_as_assistant() {
1129        let json = r#"{
1130            "type": "assistant",
1131            "message": {
1132                "id": "msg_1",
1133                "role": "assistant",
1134                "model": "claude-sonnet-4",
1135                "content": []
1136            },
1137            "session_id": "abc"
1138        }"#;
1139        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1140
1141        let assistant = output.as_assistant();
1142        assert!(assistant.is_some());
1143        assert_eq!(assistant.unwrap().message.model, "claude-sonnet-4");
1144
1145        // Not an assistant
1146        let result_json = r#"{
1147            "type": "result",
1148            "subtype": "success",
1149            "is_error": false,
1150            "duration_ms": 100,
1151            "duration_api_ms": 200,
1152            "num_turns": 1,
1153            "session_id": "abc",
1154            "total_cost_usd": 0.01
1155        }"#;
1156        let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1157        assert!(result.as_assistant().is_none());
1158    }
1159
1160    #[test]
1161    fn test_as_result() {
1162        let json = r#"{
1163            "type": "result",
1164            "subtype": "success",
1165            "is_error": false,
1166            "duration_ms": 100,
1167            "duration_api_ms": 200,
1168            "num_turns": 5,
1169            "session_id": "abc",
1170            "total_cost_usd": 0.05
1171        }"#;
1172        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1173
1174        let result = output.as_result();
1175        assert!(result.is_some());
1176        assert_eq!(result.unwrap().num_turns, 5);
1177        assert_eq!(result.unwrap().total_cost_usd, 0.05);
1178
1179        // Not a result
1180        let assistant_json = r#"{
1181            "type": "assistant",
1182            "message": {
1183                "id": "msg_1",
1184                "role": "assistant",
1185                "model": "claude-3",
1186                "content": []
1187            },
1188            "session_id": "abc"
1189        }"#;
1190        let assistant: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
1191        assert!(assistant.as_result().is_none());
1192    }
1193
1194    #[test]
1195    fn test_as_system() {
1196        let json = r#"{
1197            "type": "system",
1198            "subtype": "init",
1199            "session_id": "abc",
1200            "model": "claude-3"
1201        }"#;
1202        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1203
1204        let system = output.as_system();
1205        assert!(system.is_some());
1206        assert!(system.unwrap().is_init());
1207
1208        // Not a system message
1209        let result_json = r#"{
1210            "type": "result",
1211            "subtype": "success",
1212            "is_error": false,
1213            "duration_ms": 100,
1214            "duration_api_ms": 200,
1215            "num_turns": 1,
1216            "session_id": "abc",
1217            "total_cost_usd": 0.01
1218        }"#;
1219        let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1220        assert!(result.as_system().is_none());
1221    }
1222}