Skip to main content

claude_codes/io/
claude_output.rs

1use serde::{Deserialize, Deserializer, Serialize};
2use serde_json::{Map, Value};
3
4use super::content_blocks::{ContentBlock, ToolUseBlock};
5use super::control::{ControlRequest, ControlResponse};
6use super::errors::{AnthropicError, ParseError};
7use super::message_types::{AssistantMessage, SystemMessage, UserMessage};
8use super::rate_limit::RateLimitEvent;
9use super::result::ResultMessage;
10
11/// Top-level enum for all possible Claude output messages
12#[derive(Debug, Clone, Serialize)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum ClaudeOutput {
15    /// System initialization message
16    System(SystemMessage),
17
18    /// User message echoed back
19    User(UserMessage),
20
21    /// Assistant response
22    Assistant(AssistantMessage),
23
24    /// Result message (completion of a query)
25    Result(ResultMessage),
26
27    /// Claude Code internal workflow-journal result event.
28    #[serde(rename = "result")]
29    TranscriptResult(TranscriptMessage),
30
31    /// Control request from CLI (tool permissions, hooks, etc.)
32    ControlRequest(ControlRequest),
33
34    /// Control response from CLI (ack for initialization, etc.)
35    ControlResponse(ControlResponse),
36
37    /// API error from Anthropic (500, 529 overloaded, etc.)
38    Error(AnthropicError),
39
40    /// Rate limit status event
41    RateLimitEvent(RateLimitEvent),
42
43    /// Raw API stream event emitted with `--include-partial-messages`.
44    StreamEvent(StreamEventMessage),
45
46    /// Progress update for a running tool.
47    ToolProgress(ToolProgressMessage),
48
49    /// Authentication status update.
50    AuthStatus(AuthStatusMessage),
51
52    /// Summary of preceding tool uses.
53    ToolUseSummary(ToolUseSummaryMessage),
54
55    /// Predicted next user prompt.
56    PromptSuggestion(PromptSuggestionMessage),
57
58    /// Conversation reset notification.
59    ConversationReset(ConversationResetMessage),
60
61    /// Claude Code internal transcript progress event.
62    Progress(TranscriptMessage),
63
64    /// Claude Code internal queue event.
65    #[serde(rename = "queue-operation")]
66    QueueOperation(TranscriptMessage),
67
68    /// Claude Code internal PR link metadata event.
69    #[serde(rename = "pr-link")]
70    PrLink(TranscriptMessage),
71
72    /// Claude Code internal file history snapshot event.
73    #[serde(rename = "file-history-snapshot")]
74    FileHistorySnapshot(TranscriptMessage),
75
76    /// Claude Code internal session summary event.
77    Summary(TranscriptMessage),
78
79    /// Claude Code internal mode metadata event.
80    Mode(TranscriptMessage),
81
82    /// Claude Code internal permission mode metadata event.
83    #[serde(rename = "permission-mode")]
84    PermissionMode(TranscriptMessage),
85
86    /// Claude Code internal attachment metadata event.
87    Attachment(TranscriptMessage),
88
89    /// Claude Code internal AI-generated title event.
90    #[serde(rename = "ai-title")]
91    AiTitle(TranscriptMessage),
92
93    /// Claude Code internal last prompt pointer event.
94    #[serde(rename = "last-prompt")]
95    LastPrompt(TranscriptMessage),
96
97    /// Claude Code internal session started event.
98    Started(TranscriptMessage),
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct StreamEventMessage {
103    pub event: Value,
104    pub parent_tool_use_id: Option<String>,
105    pub uuid: String,
106    pub session_id: String,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub ttft_ms: Option<u64>,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct ToolProgressMessage {
113    pub tool_use_id: String,
114    pub tool_name: String,
115    pub parent_tool_use_id: Option<String>,
116    pub elapsed_time_seconds: f64,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub task_id: Option<String>,
119    pub uuid: String,
120    pub session_id: String,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct AuthStatusMessage {
125    #[serde(rename = "isAuthenticating")]
126    pub is_authenticating: bool,
127    pub output: Vec<String>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub error: Option<String>,
130    pub uuid: String,
131    pub session_id: String,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct ToolUseSummaryMessage {
136    pub summary: String,
137    pub preceding_tool_use_ids: Vec<String>,
138    pub uuid: String,
139    pub session_id: String,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub timestamp: Option<String>,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct PromptSuggestionMessage {
146    pub suggestion: String,
147    pub uuid: String,
148    pub session_id: String,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct ConversationResetMessage {
153    pub new_conversation_id: String,
154    pub uuid: String,
155    pub session_id: String,
156}
157
158/// Raw preserved record for Claude Code transcript-only message types.
159///
160/// These events are emitted in `~/.claude/projects/**/*.jsonl`, not in the
161/// public `--output-format stream-json` protocol. Keeping them typed at the
162/// top level lets corpus tests parse real transcript files without losing the
163/// original payload.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct TranscriptMessage {
166    #[serde(flatten)]
167    pub data: Map<String, Value>,
168}
169
170impl ClaudeOutput {
171    /// Get the message type as a string
172    pub fn message_type(&self) -> String {
173        match self {
174            ClaudeOutput::System(_) => "system".to_string(),
175            ClaudeOutput::User(_) => "user".to_string(),
176            ClaudeOutput::Assistant(_) => "assistant".to_string(),
177            ClaudeOutput::Result(_) => "result".to_string(),
178            ClaudeOutput::TranscriptResult(_) => "result".to_string(),
179            ClaudeOutput::ControlRequest(_) => "control_request".to_string(),
180            ClaudeOutput::ControlResponse(_) => "control_response".to_string(),
181            ClaudeOutput::Error(_) => "error".to_string(),
182            ClaudeOutput::RateLimitEvent(_) => "rate_limit_event".to_string(),
183            ClaudeOutput::StreamEvent(_) => "stream_event".to_string(),
184            ClaudeOutput::ToolProgress(_) => "tool_progress".to_string(),
185            ClaudeOutput::AuthStatus(_) => "auth_status".to_string(),
186            ClaudeOutput::ToolUseSummary(_) => "tool_use_summary".to_string(),
187            ClaudeOutput::PromptSuggestion(_) => "prompt_suggestion".to_string(),
188            ClaudeOutput::ConversationReset(_) => "conversation_reset".to_string(),
189            ClaudeOutput::Progress(_) => "progress".to_string(),
190            ClaudeOutput::QueueOperation(_) => "queue-operation".to_string(),
191            ClaudeOutput::PrLink(_) => "pr-link".to_string(),
192            ClaudeOutput::FileHistorySnapshot(_) => "file-history-snapshot".to_string(),
193            ClaudeOutput::Summary(_) => "summary".to_string(),
194            ClaudeOutput::Mode(_) => "mode".to_string(),
195            ClaudeOutput::PermissionMode(_) => "permission-mode".to_string(),
196            ClaudeOutput::Attachment(_) => "attachment".to_string(),
197            ClaudeOutput::AiTitle(_) => "ai-title".to_string(),
198            ClaudeOutput::LastPrompt(_) => "last-prompt".to_string(),
199            ClaudeOutput::Started(_) => "started".to_string(),
200        }
201    }
202
203    /// Check if this is a control request (tool permission request)
204    pub fn is_control_request(&self) -> bool {
205        matches!(self, ClaudeOutput::ControlRequest(_))
206    }
207
208    /// Check if this is a control response
209    pub fn is_control_response(&self) -> bool {
210        matches!(self, ClaudeOutput::ControlResponse(_))
211    }
212
213    /// Check if this is an Anthropic API error
214    pub fn is_api_error(&self) -> bool {
215        matches!(self, ClaudeOutput::Error(_))
216    }
217
218    /// Get the control request if this is one
219    pub fn as_control_request(&self) -> Option<&ControlRequest> {
220        match self {
221            ClaudeOutput::ControlRequest(req) => Some(req),
222            _ => None,
223        }
224    }
225
226    /// Get the Anthropic error if this is one
227    ///
228    /// # Example
229    /// ```
230    /// use claude_codes::ClaudeOutput;
231    ///
232    /// let json = r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#;
233    /// let output: ClaudeOutput = serde_json::from_str(json).unwrap();
234    ///
235    /// if let Some(err) = output.as_anthropic_error() {
236    ///     if err.is_overloaded() {
237    ///         println!("API is overloaded, retrying...");
238    ///     }
239    /// }
240    /// ```
241    pub fn as_anthropic_error(&self) -> Option<&AnthropicError> {
242        match self {
243            ClaudeOutput::Error(err) => Some(err),
244            _ => None,
245        }
246    }
247
248    /// Check if this is a rate limit event
249    pub fn is_rate_limit_event(&self) -> bool {
250        matches!(self, ClaudeOutput::RateLimitEvent(_))
251    }
252
253    /// Get the rate limit event if this is one
254    pub fn as_rate_limit_event(&self) -> Option<&RateLimitEvent> {
255        match self {
256            ClaudeOutput::RateLimitEvent(evt) => Some(evt),
257            _ => None,
258        }
259    }
260
261    /// Check if this is a result with error
262    pub fn is_error(&self) -> bool {
263        matches!(self, ClaudeOutput::Result(r) if r.is_error)
264    }
265
266    /// Check if this is an assistant message
267    pub fn is_assistant_message(&self) -> bool {
268        matches!(self, ClaudeOutput::Assistant(_))
269    }
270
271    /// Check if this is a system message
272    pub fn is_system_message(&self) -> bool {
273        matches!(self, ClaudeOutput::System(_))
274    }
275
276    /// Check if this is a system init message
277    ///
278    /// # Example
279    /// ```
280    /// use claude_codes::ClaudeOutput;
281    ///
282    /// let json = r#"{"type":"system","subtype":"init","session_id":"abc"}"#;
283    /// let output: ClaudeOutput = serde_json::from_str(json).unwrap();
284    /// assert!(output.is_system_init());
285    /// ```
286    pub fn is_system_init(&self) -> bool {
287        matches!(self, ClaudeOutput::System(sys) if sys.is_init())
288    }
289
290    /// Get the session ID from any message type that has one.
291    ///
292    /// Returns the session ID from System, Assistant, or Result messages.
293    /// Returns `None` for User, ControlRequest, and ControlResponse messages.
294    ///
295    /// # Example
296    /// ```
297    /// use claude_codes::ClaudeOutput;
298    ///
299    /// let json = r#"{"type":"result","subtype":"success","is_error":false,
300    ///     "duration_ms":100,"duration_api_ms":200,"num_turns":1,
301    ///     "session_id":"my-session","total_cost_usd":0.01}"#;
302    /// let output: ClaudeOutput = serde_json::from_str(json).unwrap();
303    /// assert_eq!(output.session_id(), Some("my-session"));
304    /// ```
305    pub fn session_id(&self) -> Option<&str> {
306        match self {
307            ClaudeOutput::System(sys) => sys
308                .data
309                .get("session_id")
310                .or_else(|| sys.data.get("sessionId"))
311                .and_then(|v| v.as_str()),
312            ClaudeOutput::Assistant(ass) => Some(&ass.session_id),
313            ClaudeOutput::Result(res) => Some(&res.session_id),
314            ClaudeOutput::TranscriptResult(msg) => msg
315                .data
316                .get("session_id")
317                .or_else(|| msg.data.get("sessionId"))
318                .and_then(|v| v.as_str()),
319            ClaudeOutput::User(_) => None,
320            ClaudeOutput::ControlRequest(_) => None,
321            ClaudeOutput::ControlResponse(_) => None,
322            ClaudeOutput::Error(_) => None,
323            ClaudeOutput::RateLimitEvent(evt) => Some(&evt.session_id),
324            ClaudeOutput::StreamEvent(msg) => Some(&msg.session_id),
325            ClaudeOutput::ToolProgress(msg) => Some(&msg.session_id),
326            ClaudeOutput::AuthStatus(msg) => Some(&msg.session_id),
327            ClaudeOutput::ToolUseSummary(msg) => Some(&msg.session_id),
328            ClaudeOutput::PromptSuggestion(msg) => Some(&msg.session_id),
329            ClaudeOutput::ConversationReset(msg) => Some(&msg.session_id),
330            ClaudeOutput::Progress(msg)
331            | ClaudeOutput::QueueOperation(msg)
332            | ClaudeOutput::PrLink(msg)
333            | ClaudeOutput::FileHistorySnapshot(msg)
334            | ClaudeOutput::Summary(msg)
335            | ClaudeOutput::Mode(msg)
336            | ClaudeOutput::PermissionMode(msg)
337            | ClaudeOutput::Attachment(msg)
338            | ClaudeOutput::AiTitle(msg)
339            | ClaudeOutput::LastPrompt(msg)
340            | ClaudeOutput::Started(msg) => msg
341                .data
342                .get("session_id")
343                .or_else(|| msg.data.get("sessionId"))
344                .and_then(|v| v.as_str()),
345        }
346    }
347
348    /// Get a specific tool use by name from an assistant message.
349    ///
350    /// Returns the first `ToolUseBlock` with the given name, or `None` if this
351    /// is not an assistant message or doesn't contain the specified tool.
352    ///
353    /// # Example
354    /// ```
355    /// use claude_codes::ClaudeOutput;
356    ///
357    /// let json = r#"{"type":"assistant","message":{"id":"msg_1","role":"assistant",
358    ///     "model":"claude-3","content":[{"type":"tool_use","id":"tu_1",
359    ///     "name":"Bash","input":{"command":"ls"}}]},"session_id":"abc"}"#;
360    /// let output: ClaudeOutput = serde_json::from_str(json).unwrap();
361    ///
362    /// if let Some(bash) = output.as_tool_use("Bash") {
363    ///     assert_eq!(bash.name, "Bash");
364    /// }
365    /// ```
366    pub fn as_tool_use(&self, tool_name: &str) -> Option<&ToolUseBlock> {
367        match self {
368            ClaudeOutput::Assistant(ass) => {
369                ass.message.content.iter().find_map(|block| match block {
370                    ContentBlock::ToolUse(tu) if tu.name == tool_name => Some(tu),
371                    _ => None,
372                })
373            }
374            _ => None,
375        }
376    }
377
378    /// Get all tool uses from an assistant message.
379    ///
380    /// Returns an iterator over all `ToolUseBlock`s in the message, or an empty
381    /// iterator if this is not an assistant message.
382    ///
383    /// # Example
384    /// ```
385    /// use claude_codes::ClaudeOutput;
386    ///
387    /// let json = r#"{"type":"assistant","message":{"id":"msg_1","role":"assistant",
388    ///     "model":"claude-3","content":[
389    ///         {"type":"tool_use","id":"tu_1","name":"Read","input":{"file_path":"/tmp/a"}},
390    ///         {"type":"tool_use","id":"tu_2","name":"Write","input":{"file_path":"/tmp/b","content":"x"}}
391    ///     ]},"session_id":"abc"}"#;
392    /// let output: ClaudeOutput = serde_json::from_str(json).unwrap();
393    ///
394    /// let tools: Vec<_> = output.tool_uses().collect();
395    /// assert_eq!(tools.len(), 2);
396    /// ```
397    pub fn tool_uses(&self) -> impl Iterator<Item = &ToolUseBlock> {
398        let content = match self {
399            ClaudeOutput::Assistant(ass) => Some(&ass.message.content),
400            _ => None,
401        };
402
403        content
404            .into_iter()
405            .flat_map(|c| c.iter())
406            .filter_map(|block| match block {
407                ContentBlock::ToolUse(tu) => Some(tu),
408                _ => None,
409            })
410    }
411
412    /// Get text content from an assistant message.
413    ///
414    /// Returns the concatenated text from all text blocks in the message,
415    /// or `None` if this is not an assistant message or has no text content.
416    ///
417    /// # Example
418    /// ```
419    /// use claude_codes::ClaudeOutput;
420    ///
421    /// let json = r#"{"type":"assistant","message":{"id":"msg_1","role":"assistant",
422    ///     "model":"claude-3","content":[{"type":"text","text":"Hello, world!"}]},
423    ///     "session_id":"abc"}"#;
424    /// let output: ClaudeOutput = serde_json::from_str(json).unwrap();
425    /// assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
426    /// ```
427    pub fn text_content(&self) -> Option<String> {
428        match self {
429            ClaudeOutput::Assistant(ass) => {
430                let texts: Vec<&str> = ass
431                    .message
432                    .content
433                    .iter()
434                    .filter_map(|block| match block {
435                        ContentBlock::Text(t) => Some(t.text.as_str()),
436                        _ => None,
437                    })
438                    .collect();
439
440                if texts.is_empty() {
441                    None
442                } else {
443                    Some(texts.join(""))
444                }
445            }
446            _ => None,
447        }
448    }
449
450    /// Get the assistant message if this is one.
451    ///
452    /// # Example
453    /// ```
454    /// use claude_codes::ClaudeOutput;
455    ///
456    /// let json = r#"{"type":"assistant","message":{"id":"msg_1","role":"assistant",
457    ///     "model":"claude-3","content":[]},"session_id":"abc"}"#;
458    /// let output: ClaudeOutput = serde_json::from_str(json).unwrap();
459    ///
460    /// if let Some(assistant) = output.as_assistant() {
461    ///     assert_eq!(assistant.message.model, "claude-3");
462    /// }
463    /// ```
464    pub fn as_assistant(&self) -> Option<&AssistantMessage> {
465        match self {
466            ClaudeOutput::Assistant(ass) => Some(ass),
467            _ => None,
468        }
469    }
470
471    /// Get the result message if this is one.
472    ///
473    /// # Example
474    /// ```
475    /// use claude_codes::ClaudeOutput;
476    ///
477    /// let json = r#"{"type":"result","subtype":"success","is_error":false,
478    ///     "duration_ms":100,"duration_api_ms":200,"num_turns":1,
479    ///     "session_id":"abc","total_cost_usd":0.01}"#;
480    /// let output: ClaudeOutput = serde_json::from_str(json).unwrap();
481    ///
482    /// if let Some(result) = output.as_result() {
483    ///     assert!(!result.is_error);
484    /// }
485    /// ```
486    pub fn as_result(&self) -> Option<&ResultMessage> {
487        match self {
488            ClaudeOutput::Result(res) => Some(res),
489            _ => None,
490        }
491    }
492
493    /// Get the system message if this is one.
494    pub fn as_system(&self) -> Option<&SystemMessage> {
495        match self {
496            ClaudeOutput::System(sys) => Some(sys),
497            _ => None,
498        }
499    }
500
501    /// Parse a JSON string, handling potential ANSI escape codes and other prefixes
502    /// This method will:
503    /// 1. First try to parse as-is
504    /// 2. If that fails, trim until it finds a '{' and try again
505    pub fn parse_json_tolerant(s: &str) -> Result<ClaudeOutput, ParseError> {
506        // First try to parse as-is
507        match Self::parse_json(s) {
508            Ok(output) => Ok(output),
509            Err(first_error) => {
510                // If that fails, look for the first '{' character
511                if let Some(json_start) = s.find('{') {
512                    let trimmed = &s[json_start..];
513                    match Self::parse_json(trimmed) {
514                        Ok(output) => Ok(output),
515                        Err(_) => {
516                            // Return the original error if both attempts fail
517                            Err(first_error)
518                        }
519                    }
520                } else {
521                    Err(first_error)
522                }
523            }
524        }
525    }
526
527    /// Parse a JSON string, returning ParseError with raw JSON if it doesn't match our types
528    pub fn parse_json(s: &str) -> Result<ClaudeOutput, ParseError> {
529        // First try to parse as a Value
530        let value: Value = serde_json::from_str(s).map_err(|e| ParseError {
531            raw_line: s.to_string(),
532            raw_json: None,
533            error_message: format!("Invalid JSON: {}", e),
534        })?;
535
536        // Then try to parse that Value as ClaudeOutput
537        serde_json::from_value::<ClaudeOutput>(value.clone()).map_err(|e| ParseError {
538            raw_line: s.to_string(),
539            raw_json: Some(value),
540            error_message: e.to_string(),
541        })
542    }
543}
544
545impl<'de> Deserialize<'de> for ClaudeOutput {
546    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
547        let value = Value::deserialize(deserializer)?;
548        let message_type = value
549            .get("type")
550            .and_then(|v| v.as_str())
551            .ok_or_else(|| serde::de::Error::missing_field("type"))?;
552        let mut payload = value.clone();
553        if let Some(obj) = payload.as_object_mut() {
554            obj.remove("type");
555        }
556
557        fn parse<T, E>(value: Value) -> Result<T, E>
558        where
559            T: serde::de::DeserializeOwned,
560            E: serde::de::Error,
561        {
562            serde_json::from_value(value).map_err(E::custom)
563        }
564
565        match message_type {
566            "system" => parse(payload).map(Self::System),
567            "user" => parse(payload).map(Self::User),
568            "assistant" => parse(payload).map(Self::Assistant),
569            "result" if value.get("subtype").is_some() => parse(payload).map(Self::Result),
570            "result" => parse(payload).map(Self::TranscriptResult),
571            "control_request" => parse(payload).map(Self::ControlRequest),
572            "control_response" => parse(payload).map(Self::ControlResponse),
573            "error" => parse(payload).map(Self::Error),
574            "rate_limit_event" => parse(payload).map(Self::RateLimitEvent),
575            "stream_event" => parse(payload).map(Self::StreamEvent),
576            "tool_progress" => parse(payload).map(Self::ToolProgress),
577            "auth_status" => parse(payload).map(Self::AuthStatus),
578            "tool_use_summary" => parse(payload).map(Self::ToolUseSummary),
579            "prompt_suggestion" => parse(payload).map(Self::PromptSuggestion),
580            "conversation_reset" => parse(payload).map(Self::ConversationReset),
581            "progress" => parse(payload).map(Self::Progress),
582            "queue-operation" => parse(payload).map(Self::QueueOperation),
583            "pr-link" => parse(payload).map(Self::PrLink),
584            "file-history-snapshot" => parse(payload).map(Self::FileHistorySnapshot),
585            "summary" => parse(payload).map(Self::Summary),
586            "mode" => parse(payload).map(Self::Mode),
587            "permission-mode" => parse(payload).map(Self::PermissionMode),
588            "attachment" => parse(payload).map(Self::Attachment),
589            "ai-title" => parse(payload).map(Self::AiTitle),
590            "last-prompt" => parse(payload).map(Self::LastPrompt),
591            "started" => parse(payload).map(Self::Started),
592            other => Err(serde::de::Error::unknown_variant(
593                other,
594                &[
595                    "system",
596                    "user",
597                    "assistant",
598                    "result",
599                    "control_request",
600                    "control_response",
601                    "error",
602                    "rate_limit_event",
603                    "stream_event",
604                    "tool_progress",
605                    "auth_status",
606                    "tool_use_summary",
607                    "prompt_suggestion",
608                    "conversation_reset",
609                    "progress",
610                    "queue-operation",
611                    "pr-link",
612                    "file-history-snapshot",
613                    "summary",
614                    "mode",
615                    "permission-mode",
616                    "attachment",
617                    "ai-title",
618                    "last-prompt",
619                    "started",
620                ],
621            )),
622        }
623    }
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629
630    #[test]
631    fn test_deserialize_assistant_message() {
632        let json = r#"{
633            "type": "assistant",
634            "message": {
635                "id": "msg_123",
636                "role": "assistant",
637                "model": "claude-3-sonnet",
638                "content": [{"type": "text", "text": "Hello! How can I help you?"}]
639            },
640            "session_id": "123"
641        }"#;
642
643        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
644        assert!(output.is_assistant_message());
645    }
646
647    #[test]
648    fn test_deserialize_new_top_level_message_types() {
649        let cases = [
650            (
651                r#"{"type":"stream_event","event":{"type":"content_block_delta"},"parent_tool_use_id":null,"uuid":"u1","session_id":"s1","ttft_ms":12}"#,
652                "stream_event",
653            ),
654            (
655                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"}"#,
656                "tool_progress",
657            ),
658            (
659                r#"{"type":"auth_status","isAuthenticating":true,"output":["login"],"uuid":"u3","session_id":"s3"}"#,
660                "auth_status",
661            ),
662            (
663                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"}"#,
664                "tool_use_summary",
665            ),
666            (
667                r#"{"type":"prompt_suggestion","suggestion":"Run tests","uuid":"u5","session_id":"s5"}"#,
668                "prompt_suggestion",
669            ),
670            (
671                r#"{"type":"conversation_reset","new_conversation_id":"new-session","uuid":"u6","session_id":"s6"}"#,
672                "conversation_reset",
673            ),
674        ];
675
676        for (json, message_type) in cases {
677            let output: ClaudeOutput = serde_json::from_str(json).unwrap();
678            assert_eq!(output.message_type(), message_type);
679            assert!(output.session_id().is_some());
680        }
681    }
682
683    #[test]
684    fn test_deserialize_transcript_only_message_types() {
685        let cases = [
686            (
687                r#"{"type":"progress","sessionId":"s1","content":"delta"}"#,
688                "progress",
689                Some("s1"),
690            ),
691            (
692                r#"{"type":"queue-operation","sessionId":"s2","operation":"push"}"#,
693                "queue-operation",
694                Some("s2"),
695            ),
696            (
697                r#"{"type":"pr-link","sessionId":"s3","url":"https://example.invalid/pr"}"#,
698                "pr-link",
699                Some("s3"),
700            ),
701            (
702                r#"{"type":"file-history-snapshot","sessionId":"s4","files":[]}"#,
703                "file-history-snapshot",
704                Some("s4"),
705            ),
706            (r#"{"type":"summary","summary":"Done"}"#, "summary", None),
707            (
708                r#"{"type":"mode","mode":"normal","sessionId":"s5"}"#,
709                "mode",
710                Some("s5"),
711            ),
712            (
713                r#"{"type":"permission-mode","permissionMode":"default","sessionId":"s6"}"#,
714                "permission-mode",
715                Some("s6"),
716            ),
717            (
718                r#"{"type":"attachment","attachment":{"type":"task_reminder"},"sessionId":"s7"}"#,
719                "attachment",
720                Some("s7"),
721            ),
722            (
723                r#"{"type":"ai-title","aiTitle":"Title","sessionId":"s8"}"#,
724                "ai-title",
725                Some("s8"),
726            ),
727            (
728                r#"{"type":"last-prompt","lastPrompt":"prompt","sessionId":"s9"}"#,
729                "last-prompt",
730                Some("s9"),
731            ),
732            (
733                r#"{"type":"started","sessionId":"s10"}"#,
734                "started",
735                Some("s10"),
736            ),
737            (
738                r#"{"type":"result","key":"k","agentId":"a","result":{"ok":true}}"#,
739                "result",
740                None,
741            ),
742        ];
743
744        for (json, message_type, session_id) in cases {
745            let output: ClaudeOutput = serde_json::from_str(json).unwrap();
746            assert_eq!(output.message_type(), message_type);
747            assert_eq!(output.session_id(), session_id);
748        }
749    }
750
751    #[test]
752    fn test_transcript_assistant_accepts_camel_case_session_id() {
753        let json = r#"{
754            "type": "assistant",
755            "sessionId": "camel-session",
756            "message": {
757                "id": "msg_123",
758                "role": "assistant",
759                "model": "claude-3-sonnet",
760                "content": [{"type": "text", "text": "Hello"}]
761            }
762        }"#;
763
764        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
765        assert_eq!(output.session_id(), Some("camel-session"));
766    }
767
768    #[test]
769    fn test_is_system_init() {
770        let init_json = r#"{
771            "type": "system",
772            "subtype": "init",
773            "session_id": "test-session"
774        }"#;
775        let output: ClaudeOutput = serde_json::from_str(init_json).unwrap();
776        assert!(output.is_system_init());
777
778        let status_json = r#"{
779            "type": "system",
780            "subtype": "status",
781            "session_id": "test-session"
782        }"#;
783        let output: ClaudeOutput = serde_json::from_str(status_json).unwrap();
784        assert!(!output.is_system_init());
785    }
786
787    #[test]
788    fn test_session_id() {
789        // Result message
790        let result_json = r#"{
791            "type": "result",
792            "subtype": "success",
793            "is_error": false,
794            "duration_ms": 100,
795            "duration_api_ms": 200,
796            "num_turns": 1,
797            "session_id": "result-session",
798            "total_cost_usd": 0.01
799        }"#;
800        let output: ClaudeOutput = serde_json::from_str(result_json).unwrap();
801        assert_eq!(output.session_id(), Some("result-session"));
802
803        // Assistant message
804        let assistant_json = r#"{
805            "type": "assistant",
806            "message": {
807                "id": "msg_1",
808                "role": "assistant",
809                "model": "claude-3",
810                "content": []
811            },
812            "session_id": "assistant-session"
813        }"#;
814        let output: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
815        assert_eq!(output.session_id(), Some("assistant-session"));
816
817        // System message
818        let system_json = r#"{
819            "type": "system",
820            "subtype": "init",
821            "session_id": "system-session"
822        }"#;
823        let output: ClaudeOutput = serde_json::from_str(system_json).unwrap();
824        assert_eq!(output.session_id(), Some("system-session"));
825    }
826
827    #[test]
828    fn test_as_tool_use() {
829        let json = r#"{
830            "type": "assistant",
831            "message": {
832                "id": "msg_1",
833                "role": "assistant",
834                "model": "claude-3",
835                "content": [
836                    {"type": "text", "text": "Let me run that command."},
837                    {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls -la"}},
838                    {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/test"}}
839                ]
840            },
841            "session_id": "abc"
842        }"#;
843        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
844
845        // Find Bash tool
846        let bash = output.as_tool_use("Bash");
847        assert!(bash.is_some());
848        assert_eq!(bash.unwrap().id, "tu_1");
849
850        // Find Read tool
851        let read = output.as_tool_use("Read");
852        assert!(read.is_some());
853        assert_eq!(read.unwrap().id, "tu_2");
854
855        // Non-existent tool
856        assert!(output.as_tool_use("Write").is_none());
857
858        // Not an assistant message
859        let result_json = r#"{
860            "type": "result",
861            "subtype": "success",
862            "is_error": false,
863            "duration_ms": 100,
864            "duration_api_ms": 200,
865            "num_turns": 1,
866            "session_id": "abc",
867            "total_cost_usd": 0.01
868        }"#;
869        let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
870        assert!(result.as_tool_use("Bash").is_none());
871    }
872
873    #[test]
874    fn test_tool_uses() {
875        let json = r#"{
876            "type": "assistant",
877            "message": {
878                "id": "msg_1",
879                "role": "assistant",
880                "model": "claude-3",
881                "content": [
882                    {"type": "text", "text": "Running commands..."},
883                    {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls"}},
884                    {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/a"}},
885                    {"type": "tool_use", "id": "tu_3", "name": "Write", "input": {"file_path": "/tmp/b", "content": "x"}}
886                ]
887            },
888            "session_id": "abc"
889        }"#;
890        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
891
892        let tools: Vec<_> = output.tool_uses().collect();
893        assert_eq!(tools.len(), 3);
894        assert_eq!(tools[0].name, "Bash");
895        assert_eq!(tools[1].name, "Read");
896        assert_eq!(tools[2].name, "Write");
897    }
898
899    #[test]
900    fn test_text_content() {
901        // Single text block
902        let json = r#"{
903            "type": "assistant",
904            "message": {
905                "id": "msg_1",
906                "role": "assistant",
907                "model": "claude-3",
908                "content": [{"type": "text", "text": "Hello, world!"}]
909            },
910            "session_id": "abc"
911        }"#;
912        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
913        assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
914
915        // Multiple text blocks
916        let json = r#"{
917            "type": "assistant",
918            "message": {
919                "id": "msg_1",
920                "role": "assistant",
921                "model": "claude-3",
922                "content": [
923                    {"type": "text", "text": "Hello, "},
924                    {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}},
925                    {"type": "text", "text": "world!"}
926                ]
927            },
928            "session_id": "abc"
929        }"#;
930        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
931        assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
932
933        // No text blocks
934        let json = r#"{
935            "type": "assistant",
936            "message": {
937                "id": "msg_1",
938                "role": "assistant",
939                "model": "claude-3",
940                "content": [{"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}}]
941            },
942            "session_id": "abc"
943        }"#;
944        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
945        assert_eq!(output.text_content(), None);
946
947        // Not an assistant message
948        let json = r#"{
949            "type": "result",
950            "subtype": "success",
951            "is_error": false,
952            "duration_ms": 100,
953            "duration_api_ms": 200,
954            "num_turns": 1,
955            "session_id": "abc",
956            "total_cost_usd": 0.01
957        }"#;
958        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
959        assert_eq!(output.text_content(), None);
960    }
961
962    #[test]
963    fn test_as_assistant() {
964        let json = r#"{
965            "type": "assistant",
966            "message": {
967                "id": "msg_1",
968                "role": "assistant",
969                "model": "claude-sonnet-4",
970                "content": []
971            },
972            "session_id": "abc"
973        }"#;
974        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
975
976        let assistant = output.as_assistant();
977        assert!(assistant.is_some());
978        assert_eq!(assistant.unwrap().message.model, "claude-sonnet-4");
979
980        // Not an assistant
981        let result_json = r#"{
982            "type": "result",
983            "subtype": "success",
984            "is_error": false,
985            "duration_ms": 100,
986            "duration_api_ms": 200,
987            "num_turns": 1,
988            "session_id": "abc",
989            "total_cost_usd": 0.01
990        }"#;
991        let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
992        assert!(result.as_assistant().is_none());
993    }
994
995    #[test]
996    fn test_as_result() {
997        let json = r#"{
998            "type": "result",
999            "subtype": "success",
1000            "is_error": false,
1001            "duration_ms": 100,
1002            "duration_api_ms": 200,
1003            "num_turns": 5,
1004            "session_id": "abc",
1005            "total_cost_usd": 0.05
1006        }"#;
1007        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1008
1009        let result = output.as_result();
1010        assert!(result.is_some());
1011        assert_eq!(result.unwrap().num_turns, 5);
1012        assert_eq!(result.unwrap().total_cost_usd, 0.05);
1013
1014        // Not a result
1015        let assistant_json = r#"{
1016            "type": "assistant",
1017            "message": {
1018                "id": "msg_1",
1019                "role": "assistant",
1020                "model": "claude-3",
1021                "content": []
1022            },
1023            "session_id": "abc"
1024        }"#;
1025        let assistant: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
1026        assert!(assistant.as_result().is_none());
1027    }
1028
1029    #[test]
1030    fn test_as_system() {
1031        let json = r#"{
1032            "type": "system",
1033            "subtype": "init",
1034            "session_id": "abc",
1035            "model": "claude-3"
1036        }"#;
1037        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1038
1039        let system = output.as_system();
1040        assert!(system.is_some());
1041        assert!(system.unwrap().is_init());
1042
1043        // Not a system message
1044        let result_json = r#"{
1045            "type": "result",
1046            "subtype": "success",
1047            "is_error": false,
1048            "duration_ms": 100,
1049            "duration_api_ms": 200,
1050            "num_turns": 1,
1051            "session_id": "abc",
1052            "total_cost_usd": 0.01
1053        }"#;
1054        let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1055        assert!(result.as_system().is_none());
1056    }
1057}