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