Skip to main content

claude_codes/io/
claude_output.rs

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