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