Skip to main content

claude_codes/io/
claude_output.rs

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