Skip to main content

claude_codes/io/
claude_output.rs

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