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