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