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