Skip to main content

claude_codes/io/
claude_output.rs

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