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