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