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