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#[derive(Debug, Clone, Serialize)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum ClaudeOutput {
15 System(SystemMessage),
17
18 User(UserMessage),
20
21 Assistant(AssistantMessage),
23
24 Result(ResultMessage),
26
27 #[serde(rename = "result")]
29 TranscriptResult(TranscriptMessage),
30
31 ControlRequest(ControlRequest),
33
34 ControlResponse(ControlResponse),
36
37 Error(AnthropicError),
39
40 RateLimitEvent(RateLimitEvent),
42
43 StreamEvent(StreamEventMessage),
45
46 ToolProgress(ToolProgressMessage),
48
49 CommandLifecycle(CommandLifecycleMessage),
51
52 AuthStatus(AuthStatusMessage),
54
55 ToolUseSummary(ToolUseSummaryMessage),
57
58 PromptSuggestion(PromptSuggestionMessage),
60
61 ConversationReset(ConversationResetMessage),
63
64 Progress(TranscriptMessage),
66
67 #[serde(rename = "queue-operation")]
69 QueueOperation(TranscriptMessage),
70
71 #[serde(rename = "pr-link")]
73 PrLink(TranscriptMessage),
74
75 #[serde(rename = "file-history-snapshot")]
77 FileHistorySnapshot(TranscriptMessage),
78
79 Summary(TranscriptMessage),
81
82 Mode(TranscriptMessage),
84
85 #[serde(rename = "permission-mode")]
87 PermissionMode(TranscriptMessage),
88
89 Attachment(TranscriptMessage),
91
92 #[serde(rename = "ai-title")]
94 AiTitle(TranscriptMessage),
95
96 #[serde(rename = "last-prompt")]
98 LastPrompt(TranscriptMessage),
99
100 Started(TranscriptMessage),
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct StreamEventMessage {
106 pub event: Value,
107 pub parent_tool_use_id: Option<String>,
108 pub uuid: String,
109 pub session_id: String,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 pub ttft_ms: Option<u64>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct ToolProgressMessage {
116 pub tool_use_id: String,
117 pub tool_name: String,
118 pub parent_tool_use_id: Option<String>,
119 pub elapsed_time_seconds: f64,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 pub task_id: Option<String>,
122 pub uuid: String,
123 pub session_id: String,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub heartbeat: Option<bool>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub subagent_type: Option<String>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub subagent_retry: Option<SubagentRetry>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct SubagentRetry {
140 pub agent_id: String,
141 pub attempt: u64,
142 pub max_retries: u64,
143 pub retry_delay_ms: u64,
144 pub error_status: Option<u16>,
145 pub error_category: String,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct CommandLifecycleMessage {
157 pub command_uuid: String,
160 pub state: CommandLifecycleState,
161 pub uuid: String,
162 pub session_id: String,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Hash)]
167pub enum CommandLifecycleState {
168 Queued,
170 Started,
172 Completed,
174 Cancelled,
177 Discarded,
179 Unknown(String),
181}
182
183impl CommandLifecycleState {
184 pub fn as_str(&self) -> &str {
185 match self {
186 Self::Queued => "queued",
187 Self::Started => "started",
188 Self::Completed => "completed",
189 Self::Cancelled => "cancelled",
190 Self::Discarded => "discarded",
191 Self::Unknown(s) => s.as_str(),
192 }
193 }
194}
195
196impl std::fmt::Display for CommandLifecycleState {
197 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198 f.write_str(self.as_str())
199 }
200}
201
202impl From<&str> for CommandLifecycleState {
203 fn from(s: &str) -> Self {
204 match s {
205 "queued" => Self::Queued,
206 "started" => Self::Started,
207 "completed" => Self::Completed,
208 "cancelled" => Self::Cancelled,
209 "discarded" => Self::Discarded,
210 other => Self::Unknown(other.to_string()),
211 }
212 }
213}
214
215impl Serialize for CommandLifecycleState {
216 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
217 serializer.serialize_str(self.as_str())
218 }
219}
220
221impl<'de> Deserialize<'de> for CommandLifecycleState {
222 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
223 let s = String::deserialize(deserializer)?;
224 Ok(Self::from(s.as_str()))
225 }
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct AuthStatusMessage {
230 #[serde(rename = "isAuthenticating")]
231 pub is_authenticating: bool,
232 pub output: Vec<String>,
233 #[serde(skip_serializing_if = "Option::is_none")]
234 pub error: Option<String>,
235 pub uuid: String,
236 pub session_id: String,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct ToolUseSummaryMessage {
241 pub summary: String,
242 pub preceding_tool_use_ids: Vec<String>,
243 pub uuid: String,
244 pub session_id: String,
245 #[serde(skip_serializing_if = "Option::is_none")]
246 pub timestamp: Option<String>,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct PromptSuggestionMessage {
251 pub suggestion: String,
252 pub uuid: String,
253 pub session_id: String,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct ConversationResetMessage {
258 pub new_conversation_id: String,
259 pub uuid: String,
260 pub session_id: String,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct TranscriptMessage {
271 #[serde(flatten)]
272 pub data: Map<String, Value>,
273}
274
275impl ClaudeOutput {
276 pub fn message_type(&self) -> String {
278 match self {
279 ClaudeOutput::System(_) => "system".to_string(),
280 ClaudeOutput::User(_) => "user".to_string(),
281 ClaudeOutput::Assistant(_) => "assistant".to_string(),
282 ClaudeOutput::Result(_) => "result".to_string(),
283 ClaudeOutput::TranscriptResult(_) => "result".to_string(),
284 ClaudeOutput::ControlRequest(_) => "control_request".to_string(),
285 ClaudeOutput::ControlResponse(_) => "control_response".to_string(),
286 ClaudeOutput::Error(_) => "error".to_string(),
287 ClaudeOutput::RateLimitEvent(_) => "rate_limit_event".to_string(),
288 ClaudeOutput::StreamEvent(_) => "stream_event".to_string(),
289 ClaudeOutput::ToolProgress(_) => "tool_progress".to_string(),
290 ClaudeOutput::CommandLifecycle(_) => "command_lifecycle".to_string(),
291 ClaudeOutput::AuthStatus(_) => "auth_status".to_string(),
292 ClaudeOutput::ToolUseSummary(_) => "tool_use_summary".to_string(),
293 ClaudeOutput::PromptSuggestion(_) => "prompt_suggestion".to_string(),
294 ClaudeOutput::ConversationReset(_) => "conversation_reset".to_string(),
295 ClaudeOutput::Progress(_) => "progress".to_string(),
296 ClaudeOutput::QueueOperation(_) => "queue-operation".to_string(),
297 ClaudeOutput::PrLink(_) => "pr-link".to_string(),
298 ClaudeOutput::FileHistorySnapshot(_) => "file-history-snapshot".to_string(),
299 ClaudeOutput::Summary(_) => "summary".to_string(),
300 ClaudeOutput::Mode(_) => "mode".to_string(),
301 ClaudeOutput::PermissionMode(_) => "permission-mode".to_string(),
302 ClaudeOutput::Attachment(_) => "attachment".to_string(),
303 ClaudeOutput::AiTitle(_) => "ai-title".to_string(),
304 ClaudeOutput::LastPrompt(_) => "last-prompt".to_string(),
305 ClaudeOutput::Started(_) => "started".to_string(),
306 }
307 }
308
309 pub fn is_control_request(&self) -> bool {
311 matches!(self, ClaudeOutput::ControlRequest(_))
312 }
313
314 pub fn is_control_response(&self) -> bool {
316 matches!(self, ClaudeOutput::ControlResponse(_))
317 }
318
319 pub fn is_api_error(&self) -> bool {
321 matches!(self, ClaudeOutput::Error(_))
322 }
323
324 pub fn as_control_request(&self) -> Option<&ControlRequest> {
326 match self {
327 ClaudeOutput::ControlRequest(req) => Some(req),
328 _ => None,
329 }
330 }
331
332 pub fn as_anthropic_error(&self) -> Option<&AnthropicError> {
348 match self {
349 ClaudeOutput::Error(err) => Some(err),
350 _ => None,
351 }
352 }
353
354 pub fn is_rate_limit_event(&self) -> bool {
356 matches!(self, ClaudeOutput::RateLimitEvent(_))
357 }
358
359 pub fn as_rate_limit_event(&self) -> Option<&RateLimitEvent> {
361 match self {
362 ClaudeOutput::RateLimitEvent(evt) => Some(evt),
363 _ => None,
364 }
365 }
366
367 pub fn is_error(&self) -> bool {
369 matches!(self, ClaudeOutput::Result(r) if r.is_error)
370 }
371
372 pub fn is_assistant_message(&self) -> bool {
374 matches!(self, ClaudeOutput::Assistant(_))
375 }
376
377 pub fn is_system_message(&self) -> bool {
379 matches!(self, ClaudeOutput::System(_))
380 }
381
382 pub fn is_system_init(&self) -> bool {
393 matches!(self, ClaudeOutput::System(sys) if sys.is_init())
394 }
395
396 pub fn session_id(&self) -> Option<&str> {
412 match self {
413 ClaudeOutput::System(sys) => sys
414 .data
415 .get("session_id")
416 .or_else(|| sys.data.get("sessionId"))
417 .and_then(|v| v.as_str()),
418 ClaudeOutput::Assistant(ass) => Some(&ass.session_id),
419 ClaudeOutput::Result(res) => Some(&res.session_id),
420 ClaudeOutput::TranscriptResult(msg) => msg
421 .data
422 .get("session_id")
423 .or_else(|| msg.data.get("sessionId"))
424 .and_then(|v| v.as_str()),
425 ClaudeOutput::User(_) => None,
426 ClaudeOutput::ControlRequest(_) => None,
427 ClaudeOutput::ControlResponse(_) => None,
428 ClaudeOutput::Error(_) => None,
429 ClaudeOutput::RateLimitEvent(evt) => Some(&evt.session_id),
430 ClaudeOutput::StreamEvent(msg) => Some(&msg.session_id),
431 ClaudeOutput::ToolProgress(msg) => Some(&msg.session_id),
432 ClaudeOutput::CommandLifecycle(msg) => Some(&msg.session_id),
433 ClaudeOutput::AuthStatus(msg) => Some(&msg.session_id),
434 ClaudeOutput::ToolUseSummary(msg) => Some(&msg.session_id),
435 ClaudeOutput::PromptSuggestion(msg) => Some(&msg.session_id),
436 ClaudeOutput::ConversationReset(msg) => Some(&msg.session_id),
437 ClaudeOutput::Progress(msg)
438 | ClaudeOutput::QueueOperation(msg)
439 | ClaudeOutput::PrLink(msg)
440 | ClaudeOutput::FileHistorySnapshot(msg)
441 | ClaudeOutput::Summary(msg)
442 | ClaudeOutput::Mode(msg)
443 | ClaudeOutput::PermissionMode(msg)
444 | ClaudeOutput::Attachment(msg)
445 | ClaudeOutput::AiTitle(msg)
446 | ClaudeOutput::LastPrompt(msg)
447 | ClaudeOutput::Started(msg) => msg
448 .data
449 .get("session_id")
450 .or_else(|| msg.data.get("sessionId"))
451 .and_then(|v| v.as_str()),
452 }
453 }
454
455 pub fn as_tool_use(&self, tool_name: &str) -> Option<&ToolUseBlock> {
474 match self {
475 ClaudeOutput::Assistant(ass) => {
476 ass.message.content.iter().find_map(|block| match block {
477 ContentBlock::ToolUse(tu) if tu.name == tool_name => Some(tu),
478 _ => None,
479 })
480 }
481 _ => None,
482 }
483 }
484
485 pub fn tool_uses(&self) -> impl Iterator<Item = &ToolUseBlock> {
505 let content = match self {
506 ClaudeOutput::Assistant(ass) => Some(&ass.message.content),
507 _ => None,
508 };
509
510 content
511 .into_iter()
512 .flat_map(|c| c.iter())
513 .filter_map(|block| match block {
514 ContentBlock::ToolUse(tu) => Some(tu),
515 _ => None,
516 })
517 }
518
519 pub fn text_content(&self) -> Option<String> {
535 match self {
536 ClaudeOutput::Assistant(ass) => {
537 let texts: Vec<&str> = ass
538 .message
539 .content
540 .iter()
541 .filter_map(|block| match block {
542 ContentBlock::Text(t) => Some(t.text.as_str()),
543 _ => None,
544 })
545 .collect();
546
547 if texts.is_empty() {
548 None
549 } else {
550 Some(texts.join(""))
551 }
552 }
553 _ => None,
554 }
555 }
556
557 pub fn as_assistant(&self) -> Option<&AssistantMessage> {
572 match self {
573 ClaudeOutput::Assistant(ass) => Some(ass),
574 _ => None,
575 }
576 }
577
578 pub fn as_result(&self) -> Option<&ResultMessage> {
594 match self {
595 ClaudeOutput::Result(res) => Some(res),
596 _ => None,
597 }
598 }
599
600 pub fn as_system(&self) -> Option<&SystemMessage> {
602 match self {
603 ClaudeOutput::System(sys) => Some(sys),
604 _ => None,
605 }
606 }
607
608 pub fn parse_json_tolerant(s: &str) -> Result<ClaudeOutput, ParseError> {
613 match Self::parse_json(s) {
615 Ok(output) => Ok(output),
616 Err(first_error) => {
617 if let Some(json_start) = s.find('{') {
619 let trimmed = &s[json_start..];
620 match Self::parse_json(trimmed) {
621 Ok(output) => Ok(output),
622 Err(_) => {
623 Err(first_error)
625 }
626 }
627 } else {
628 Err(first_error)
629 }
630 }
631 }
632 }
633
634 pub fn parse_json(s: &str) -> Result<ClaudeOutput, ParseError> {
636 let value: Value = serde_json::from_str(s).map_err(|e| ParseError {
638 raw_line: s.to_string(),
639 raw_json: None,
640 error_message: format!("Invalid JSON: {}", e),
641 })?;
642
643 serde_json::from_value::<ClaudeOutput>(value.clone()).map_err(|e| ParseError {
645 raw_line: s.to_string(),
646 raw_json: Some(value),
647 error_message: e.to_string(),
648 })
649 }
650}
651
652impl<'de> Deserialize<'de> for ClaudeOutput {
653 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
654 let value = Value::deserialize(deserializer)?;
655 let message_type = value
656 .get("type")
657 .and_then(|v| v.as_str())
658 .ok_or_else(|| serde::de::Error::missing_field("type"))?;
659 let mut payload = value.clone();
660 if let Some(obj) = payload.as_object_mut() {
661 obj.remove("type");
662 }
663
664 fn parse<T, E>(value: Value) -> Result<T, E>
665 where
666 T: serde::de::DeserializeOwned,
667 E: serde::de::Error,
668 {
669 serde_json::from_value(value).map_err(E::custom)
670 }
671
672 match message_type {
673 "system" => parse(payload).map(Self::System),
674 "user" => parse(payload).map(Self::User),
675 "assistant" => parse(payload).map(Self::Assistant),
676 "result" if value.get("subtype").is_some() => parse(payload).map(Self::Result),
677 "result" => parse(payload).map(Self::TranscriptResult),
678 "control_request" => parse(payload).map(Self::ControlRequest),
679 "control_response" => parse(payload).map(Self::ControlResponse),
680 "error" => parse(payload).map(Self::Error),
681 "rate_limit_event" => parse(payload).map(Self::RateLimitEvent),
682 "stream_event" => parse(payload).map(Self::StreamEvent),
683 "tool_progress" => parse(payload).map(Self::ToolProgress),
684 "command_lifecycle" => parse(payload).map(Self::CommandLifecycle),
685 "auth_status" => parse(payload).map(Self::AuthStatus),
686 "tool_use_summary" => parse(payload).map(Self::ToolUseSummary),
687 "prompt_suggestion" => parse(payload).map(Self::PromptSuggestion),
688 "conversation_reset" => parse(payload).map(Self::ConversationReset),
689 "progress" => parse(payload).map(Self::Progress),
690 "queue-operation" => parse(payload).map(Self::QueueOperation),
691 "pr-link" => parse(payload).map(Self::PrLink),
692 "file-history-snapshot" => parse(payload).map(Self::FileHistorySnapshot),
693 "summary" => parse(payload).map(Self::Summary),
694 "mode" => parse(payload).map(Self::Mode),
695 "permission-mode" => parse(payload).map(Self::PermissionMode),
696 "attachment" => parse(payload).map(Self::Attachment),
697 "ai-title" => parse(payload).map(Self::AiTitle),
698 "last-prompt" => parse(payload).map(Self::LastPrompt),
699 "started" => parse(payload).map(Self::Started),
700 other => Err(serde::de::Error::unknown_variant(
701 other,
702 &[
703 "system",
704 "user",
705 "assistant",
706 "result",
707 "control_request",
708 "control_response",
709 "error",
710 "rate_limit_event",
711 "stream_event",
712 "tool_progress",
713 "command_lifecycle",
714 "auth_status",
715 "tool_use_summary",
716 "prompt_suggestion",
717 "conversation_reset",
718 "progress",
719 "queue-operation",
720 "pr-link",
721 "file-history-snapshot",
722 "summary",
723 "mode",
724 "permission-mode",
725 "attachment",
726 "ai-title",
727 "last-prompt",
728 "started",
729 ],
730 )),
731 }
732 }
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738
739 #[test]
740 fn test_deserialize_assistant_message() {
741 let json = r#"{
742 "type": "assistant",
743 "message": {
744 "id": "msg_123",
745 "role": "assistant",
746 "model": "claude-3-sonnet",
747 "content": [{"type": "text", "text": "Hello! How can I help you?"}]
748 },
749 "session_id": "123"
750 }"#;
751
752 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
753 assert!(output.is_assistant_message());
754 }
755
756 #[test]
757 fn test_deserialize_new_top_level_message_types() {
758 let cases = [
759 (
760 r#"{"type":"stream_event","event":{"type":"content_block_delta"},"parent_tool_use_id":null,"uuid":"u1","session_id":"s1","ttft_ms":12}"#,
761 "stream_event",
762 ),
763 (
764 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"}"#,
765 "tool_progress",
766 ),
767 (
768 r#"{"type":"auth_status","isAuthenticating":true,"output":["login"],"uuid":"u3","session_id":"s3"}"#,
769 "auth_status",
770 ),
771 (
772 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"}"#,
773 "tool_use_summary",
774 ),
775 (
776 r#"{"type":"prompt_suggestion","suggestion":"Run tests","uuid":"u5","session_id":"s5"}"#,
777 "prompt_suggestion",
778 ),
779 (
780 r#"{"type":"conversation_reset","new_conversation_id":"new-session","uuid":"u6","session_id":"s6"}"#,
781 "conversation_reset",
782 ),
783 (
784 r#"{"type":"command_lifecycle","command_uuid":"cmd-1","state":"queued","uuid":"u7","session_id":"s7"}"#,
785 "command_lifecycle",
786 ),
787 ];
788
789 for (json, message_type) in cases {
790 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
791 assert_eq!(output.message_type(), message_type);
792 assert!(output.session_id().is_some());
793 }
794 }
795
796 #[test]
797 fn test_command_lifecycle_states_roundtrip() {
798 for state in ["queued", "started", "completed", "cancelled", "discarded"] {
799 let json = format!(
800 r#"{{"type":"command_lifecycle","command_uuid":"cmd-1","state":"{}","uuid":"u1","session_id":"s1"}}"#,
801 state
802 );
803 let output: ClaudeOutput = serde_json::from_str(&json).unwrap();
804 let ClaudeOutput::CommandLifecycle(msg) = &output else {
805 panic!("expected CommandLifecycle");
806 };
807 assert_eq!(msg.state.as_str(), state);
808 assert!(!matches!(msg.state, CommandLifecycleState::Unknown(_)));
809 assert_eq!(serde_json::to_string(&output).unwrap(), json);
810 }
811
812 let json = r#"{"type":"command_lifecycle","command_uuid":"cmd-2","state":"parked","uuid":"u2","session_id":"s2"}"#;
814 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
815 let ClaudeOutput::CommandLifecycle(msg) = &output else {
816 panic!("expected CommandLifecycle");
817 };
818 assert_eq!(
819 msg.state,
820 CommandLifecycleState::Unknown("parked".to_string())
821 );
822 assert_eq!(serde_json::to_string(&output).unwrap(), json);
823 }
824
825 #[test]
826 fn test_tool_progress_heartbeat_and_subagent_retry() {
827 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"}}"#;
828 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
829 let ClaudeOutput::ToolProgress(msg) = &output else {
830 panic!("expected ToolProgress");
831 };
832 assert_eq!(msg.heartbeat, Some(true));
833 assert_eq!(msg.subagent_type.as_deref(), Some("Explore"));
834 let retry = msg.subagent_retry.as_ref().unwrap();
835 assert_eq!(retry.attempt, 2);
836 assert_eq!(retry.error_status, Some(529));
837 assert_eq!(retry.error_category, "overloaded");
838
839 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"}}"#;
841 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
842 let ClaudeOutput::ToolProgress(msg) = &output else {
843 panic!("expected ToolProgress");
844 };
845 assert_eq!(msg.subagent_retry.as_ref().unwrap().error_status, None);
846 }
847
848 #[test]
849 fn test_deserialize_transcript_only_message_types() {
850 let cases = [
851 (
852 r#"{"type":"progress","sessionId":"s1","content":"delta"}"#,
853 "progress",
854 Some("s1"),
855 ),
856 (
857 r#"{"type":"queue-operation","sessionId":"s2","operation":"push"}"#,
858 "queue-operation",
859 Some("s2"),
860 ),
861 (
862 r#"{"type":"pr-link","sessionId":"s3","url":"https://example.invalid/pr"}"#,
863 "pr-link",
864 Some("s3"),
865 ),
866 (
867 r#"{"type":"file-history-snapshot","sessionId":"s4","files":[]}"#,
868 "file-history-snapshot",
869 Some("s4"),
870 ),
871 (r#"{"type":"summary","summary":"Done"}"#, "summary", None),
872 (
873 r#"{"type":"mode","mode":"normal","sessionId":"s5"}"#,
874 "mode",
875 Some("s5"),
876 ),
877 (
878 r#"{"type":"permission-mode","permissionMode":"default","sessionId":"s6"}"#,
879 "permission-mode",
880 Some("s6"),
881 ),
882 (
883 r#"{"type":"attachment","attachment":{"type":"task_reminder"},"sessionId":"s7"}"#,
884 "attachment",
885 Some("s7"),
886 ),
887 (
888 r#"{"type":"ai-title","aiTitle":"Title","sessionId":"s8"}"#,
889 "ai-title",
890 Some("s8"),
891 ),
892 (
893 r#"{"type":"last-prompt","lastPrompt":"prompt","sessionId":"s9"}"#,
894 "last-prompt",
895 Some("s9"),
896 ),
897 (
898 r#"{"type":"started","sessionId":"s10"}"#,
899 "started",
900 Some("s10"),
901 ),
902 (
903 r#"{"type":"result","key":"k","agentId":"a","result":{"ok":true}}"#,
904 "result",
905 None,
906 ),
907 ];
908
909 for (json, message_type, session_id) in cases {
910 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
911 assert_eq!(output.message_type(), message_type);
912 assert_eq!(output.session_id(), session_id);
913 }
914 }
915
916 #[test]
917 fn test_transcript_assistant_accepts_camel_case_session_id() {
918 let json = r#"{
919 "type": "assistant",
920 "sessionId": "camel-session",
921 "message": {
922 "id": "msg_123",
923 "role": "assistant",
924 "model": "claude-3-sonnet",
925 "content": [{"type": "text", "text": "Hello"}]
926 }
927 }"#;
928
929 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
930 assert_eq!(output.session_id(), Some("camel-session"));
931 }
932
933 #[test]
934 fn test_is_system_init() {
935 let init_json = r#"{
936 "type": "system",
937 "subtype": "init",
938 "session_id": "test-session"
939 }"#;
940 let output: ClaudeOutput = serde_json::from_str(init_json).unwrap();
941 assert!(output.is_system_init());
942
943 let status_json = r#"{
944 "type": "system",
945 "subtype": "status",
946 "session_id": "test-session"
947 }"#;
948 let output: ClaudeOutput = serde_json::from_str(status_json).unwrap();
949 assert!(!output.is_system_init());
950 }
951
952 #[test]
953 fn test_session_id() {
954 let result_json = r#"{
956 "type": "result",
957 "subtype": "success",
958 "is_error": false,
959 "duration_ms": 100,
960 "duration_api_ms": 200,
961 "num_turns": 1,
962 "session_id": "result-session",
963 "total_cost_usd": 0.01
964 }"#;
965 let output: ClaudeOutput = serde_json::from_str(result_json).unwrap();
966 assert_eq!(output.session_id(), Some("result-session"));
967
968 let assistant_json = r#"{
970 "type": "assistant",
971 "message": {
972 "id": "msg_1",
973 "role": "assistant",
974 "model": "claude-3",
975 "content": []
976 },
977 "session_id": "assistant-session"
978 }"#;
979 let output: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
980 assert_eq!(output.session_id(), Some("assistant-session"));
981
982 let system_json = r#"{
984 "type": "system",
985 "subtype": "init",
986 "session_id": "system-session"
987 }"#;
988 let output: ClaudeOutput = serde_json::from_str(system_json).unwrap();
989 assert_eq!(output.session_id(), Some("system-session"));
990 }
991
992 #[test]
993 fn test_as_tool_use() {
994 let json = r#"{
995 "type": "assistant",
996 "message": {
997 "id": "msg_1",
998 "role": "assistant",
999 "model": "claude-3",
1000 "content": [
1001 {"type": "text", "text": "Let me run that command."},
1002 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls -la"}},
1003 {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/test"}}
1004 ]
1005 },
1006 "session_id": "abc"
1007 }"#;
1008 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1009
1010 let bash = output.as_tool_use("Bash");
1012 assert!(bash.is_some());
1013 assert_eq!(bash.unwrap().id, "tu_1");
1014
1015 let read = output.as_tool_use("Read");
1017 assert!(read.is_some());
1018 assert_eq!(read.unwrap().id, "tu_2");
1019
1020 assert!(output.as_tool_use("Write").is_none());
1022
1023 let result_json = r#"{
1025 "type": "result",
1026 "subtype": "success",
1027 "is_error": false,
1028 "duration_ms": 100,
1029 "duration_api_ms": 200,
1030 "num_turns": 1,
1031 "session_id": "abc",
1032 "total_cost_usd": 0.01
1033 }"#;
1034 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1035 assert!(result.as_tool_use("Bash").is_none());
1036 }
1037
1038 #[test]
1039 fn test_tool_uses() {
1040 let json = r#"{
1041 "type": "assistant",
1042 "message": {
1043 "id": "msg_1",
1044 "role": "assistant",
1045 "model": "claude-3",
1046 "content": [
1047 {"type": "text", "text": "Running commands..."},
1048 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls"}},
1049 {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/a"}},
1050 {"type": "tool_use", "id": "tu_3", "name": "Write", "input": {"file_path": "/tmp/b", "content": "x"}}
1051 ]
1052 },
1053 "session_id": "abc"
1054 }"#;
1055 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1056
1057 let tools: Vec<_> = output.tool_uses().collect();
1058 assert_eq!(tools.len(), 3);
1059 assert_eq!(tools[0].name, "Bash");
1060 assert_eq!(tools[1].name, "Read");
1061 assert_eq!(tools[2].name, "Write");
1062 }
1063
1064 #[test]
1065 fn test_text_content() {
1066 let json = r#"{
1068 "type": "assistant",
1069 "message": {
1070 "id": "msg_1",
1071 "role": "assistant",
1072 "model": "claude-3",
1073 "content": [{"type": "text", "text": "Hello, world!"}]
1074 },
1075 "session_id": "abc"
1076 }"#;
1077 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1078 assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
1079
1080 let json = r#"{
1082 "type": "assistant",
1083 "message": {
1084 "id": "msg_1",
1085 "role": "assistant",
1086 "model": "claude-3",
1087 "content": [
1088 {"type": "text", "text": "Hello, "},
1089 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}},
1090 {"type": "text", "text": "world!"}
1091 ]
1092 },
1093 "session_id": "abc"
1094 }"#;
1095 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1096 assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
1097
1098 let json = r#"{
1100 "type": "assistant",
1101 "message": {
1102 "id": "msg_1",
1103 "role": "assistant",
1104 "model": "claude-3",
1105 "content": [{"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}}]
1106 },
1107 "session_id": "abc"
1108 }"#;
1109 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1110 assert_eq!(output.text_content(), None);
1111
1112 let json = r#"{
1114 "type": "result",
1115 "subtype": "success",
1116 "is_error": false,
1117 "duration_ms": 100,
1118 "duration_api_ms": 200,
1119 "num_turns": 1,
1120 "session_id": "abc",
1121 "total_cost_usd": 0.01
1122 }"#;
1123 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1124 assert_eq!(output.text_content(), None);
1125 }
1126
1127 #[test]
1128 fn test_as_assistant() {
1129 let json = r#"{
1130 "type": "assistant",
1131 "message": {
1132 "id": "msg_1",
1133 "role": "assistant",
1134 "model": "claude-sonnet-4",
1135 "content": []
1136 },
1137 "session_id": "abc"
1138 }"#;
1139 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1140
1141 let assistant = output.as_assistant();
1142 assert!(assistant.is_some());
1143 assert_eq!(assistant.unwrap().message.model, "claude-sonnet-4");
1144
1145 let result_json = r#"{
1147 "type": "result",
1148 "subtype": "success",
1149 "is_error": false,
1150 "duration_ms": 100,
1151 "duration_api_ms": 200,
1152 "num_turns": 1,
1153 "session_id": "abc",
1154 "total_cost_usd": 0.01
1155 }"#;
1156 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1157 assert!(result.as_assistant().is_none());
1158 }
1159
1160 #[test]
1161 fn test_as_result() {
1162 let json = r#"{
1163 "type": "result",
1164 "subtype": "success",
1165 "is_error": false,
1166 "duration_ms": 100,
1167 "duration_api_ms": 200,
1168 "num_turns": 5,
1169 "session_id": "abc",
1170 "total_cost_usd": 0.05
1171 }"#;
1172 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1173
1174 let result = output.as_result();
1175 assert!(result.is_some());
1176 assert_eq!(result.unwrap().num_turns, 5);
1177 assert_eq!(result.unwrap().total_cost_usd, 0.05);
1178
1179 let assistant_json = r#"{
1181 "type": "assistant",
1182 "message": {
1183 "id": "msg_1",
1184 "role": "assistant",
1185 "model": "claude-3",
1186 "content": []
1187 },
1188 "session_id": "abc"
1189 }"#;
1190 let assistant: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
1191 assert!(assistant.as_result().is_none());
1192 }
1193
1194 #[test]
1195 fn test_as_system() {
1196 let json = r#"{
1197 "type": "system",
1198 "subtype": "init",
1199 "session_id": "abc",
1200 "model": "claude-3"
1201 }"#;
1202 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1203
1204 let system = output.as_system();
1205 assert!(system.is_some());
1206 assert!(system.unwrap().is_init());
1207
1208 let result_json = r#"{
1210 "type": "result",
1211 "subtype": "success",
1212 "is_error": false,
1213 "duration_ms": 100,
1214 "duration_api_ms": 200,
1215 "num_turns": 1,
1216 "session_id": "abc",
1217 "total_cost_usd": 0.01
1218 }"#;
1219 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1220 assert!(result.as_system().is_none());
1221 }
1222}