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)]
274pub struct ConversationResetMessage {
275 pub new_conversation_id: String,
279 pub uuid: String,
280 pub session_id: String,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct TranscriptMessage {
292 #[serde(flatten)]
293 pub data: Map<String, Value>,
294}
295
296impl ClaudeOutput {
297 pub fn message_type(&self) -> String {
299 match self {
300 ClaudeOutput::System(_) => "system".to_string(),
301 ClaudeOutput::User(_) => "user".to_string(),
302 ClaudeOutput::Assistant(_) => "assistant".to_string(),
303 ClaudeOutput::Result(_) => "result".to_string(),
304 ClaudeOutput::TranscriptResult(_) => "result".to_string(),
305 ClaudeOutput::ControlRequest(_) => "control_request".to_string(),
306 ClaudeOutput::ControlResponse(_) => "control_response".to_string(),
307 ClaudeOutput::Error(_) => "error".to_string(),
308 ClaudeOutput::RateLimitEvent(_) => "rate_limit_event".to_string(),
309 ClaudeOutput::StreamEvent(_) => "stream_event".to_string(),
310 ClaudeOutput::ToolProgress(_) => "tool_progress".to_string(),
311 ClaudeOutput::CommandLifecycle(_) => "command_lifecycle".to_string(),
312 ClaudeOutput::AuthStatus(_) => "auth_status".to_string(),
313 ClaudeOutput::ToolUseSummary(_) => "tool_use_summary".to_string(),
314 ClaudeOutput::PromptSuggestion(_) => "prompt_suggestion".to_string(),
315 ClaudeOutput::ConversationReset(_) => "conversation_reset".to_string(),
316 ClaudeOutput::Progress(_) => "progress".to_string(),
317 ClaudeOutput::QueueOperation(_) => "queue-operation".to_string(),
318 ClaudeOutput::PrLink(_) => "pr-link".to_string(),
319 ClaudeOutput::FileHistorySnapshot(_) => "file-history-snapshot".to_string(),
320 ClaudeOutput::Summary(_) => "summary".to_string(),
321 ClaudeOutput::Mode(_) => "mode".to_string(),
322 ClaudeOutput::PermissionMode(_) => "permission-mode".to_string(),
323 ClaudeOutput::Attachment(_) => "attachment".to_string(),
324 ClaudeOutput::AiTitle(_) => "ai-title".to_string(),
325 ClaudeOutput::LastPrompt(_) => "last-prompt".to_string(),
326 ClaudeOutput::Started(_) => "started".to_string(),
327 }
328 }
329
330 pub fn is_control_request(&self) -> bool {
332 matches!(self, ClaudeOutput::ControlRequest(_))
333 }
334
335 pub fn is_control_response(&self) -> bool {
337 matches!(self, ClaudeOutput::ControlResponse(_))
338 }
339
340 pub fn is_api_error(&self) -> bool {
342 matches!(self, ClaudeOutput::Error(_))
343 }
344
345 pub fn as_control_request(&self) -> Option<&ControlRequest> {
347 match self {
348 ClaudeOutput::ControlRequest(req) => Some(req),
349 _ => None,
350 }
351 }
352
353 pub fn as_anthropic_error(&self) -> Option<&AnthropicError> {
369 match self {
370 ClaudeOutput::Error(err) => Some(err),
371 _ => None,
372 }
373 }
374
375 pub fn is_rate_limit_event(&self) -> bool {
377 matches!(self, ClaudeOutput::RateLimitEvent(_))
378 }
379
380 pub fn as_rate_limit_event(&self) -> Option<&RateLimitEvent> {
382 match self {
383 ClaudeOutput::RateLimitEvent(evt) => Some(evt),
384 _ => None,
385 }
386 }
387
388 pub fn is_error(&self) -> bool {
390 matches!(self, ClaudeOutput::Result(r) if r.is_error)
391 }
392
393 pub fn is_assistant_message(&self) -> bool {
395 matches!(self, ClaudeOutput::Assistant(_))
396 }
397
398 pub fn is_system_message(&self) -> bool {
400 matches!(self, ClaudeOutput::System(_))
401 }
402
403 pub fn is_system_init(&self) -> bool {
414 matches!(self, ClaudeOutput::System(sys) if sys.is_init())
415 }
416
417 pub fn session_id(&self) -> Option<&str> {
433 match self {
434 ClaudeOutput::System(sys) => sys
435 .data
436 .get("session_id")
437 .or_else(|| sys.data.get("sessionId"))
438 .and_then(|v| v.as_str()),
439 ClaudeOutput::Assistant(ass) => Some(&ass.session_id),
440 ClaudeOutput::Result(res) => Some(&res.session_id),
441 ClaudeOutput::TranscriptResult(msg) => msg
442 .data
443 .get("session_id")
444 .or_else(|| msg.data.get("sessionId"))
445 .and_then(|v| v.as_str()),
446 ClaudeOutput::User(_) => None,
447 ClaudeOutput::ControlRequest(_) => None,
448 ClaudeOutput::ControlResponse(_) => None,
449 ClaudeOutput::Error(_) => None,
450 ClaudeOutput::RateLimitEvent(evt) => Some(&evt.session_id),
451 ClaudeOutput::StreamEvent(msg) => Some(&msg.session_id),
452 ClaudeOutput::ToolProgress(msg) => Some(&msg.session_id),
453 ClaudeOutput::CommandLifecycle(msg) => Some(&msg.session_id),
454 ClaudeOutput::AuthStatus(msg) => Some(&msg.session_id),
455 ClaudeOutput::ToolUseSummary(msg) => Some(&msg.session_id),
456 ClaudeOutput::PromptSuggestion(msg) => Some(&msg.session_id),
457 ClaudeOutput::ConversationReset(msg) => Some(&msg.session_id),
458 ClaudeOutput::Progress(msg)
459 | ClaudeOutput::QueueOperation(msg)
460 | ClaudeOutput::PrLink(msg)
461 | ClaudeOutput::FileHistorySnapshot(msg)
462 | ClaudeOutput::Summary(msg)
463 | ClaudeOutput::Mode(msg)
464 | ClaudeOutput::PermissionMode(msg)
465 | ClaudeOutput::Attachment(msg)
466 | ClaudeOutput::AiTitle(msg)
467 | ClaudeOutput::LastPrompt(msg)
468 | ClaudeOutput::Started(msg) => msg
469 .data
470 .get("session_id")
471 .or_else(|| msg.data.get("sessionId"))
472 .and_then(|v| v.as_str()),
473 }
474 }
475
476 pub fn as_tool_use(&self, tool_name: &str) -> Option<&ToolUseBlock> {
495 match self {
496 ClaudeOutput::Assistant(ass) => {
497 ass.message.content.iter().find_map(|block| match block {
498 ContentBlock::ToolUse(tu) if tu.name == tool_name => Some(tu),
499 _ => None,
500 })
501 }
502 _ => None,
503 }
504 }
505
506 pub fn tool_uses(&self) -> impl Iterator<Item = &ToolUseBlock> {
526 let content = match self {
527 ClaudeOutput::Assistant(ass) => Some(&ass.message.content),
528 _ => None,
529 };
530
531 content
532 .into_iter()
533 .flat_map(|c| c.iter())
534 .filter_map(|block| match block {
535 ContentBlock::ToolUse(tu) => Some(tu),
536 _ => None,
537 })
538 }
539
540 pub fn text_content(&self) -> Option<String> {
556 match self {
557 ClaudeOutput::Assistant(ass) => {
558 let texts: Vec<&str> = ass
559 .message
560 .content
561 .iter()
562 .filter_map(|block| match block {
563 ContentBlock::Text(t) => Some(t.text.as_str()),
564 _ => None,
565 })
566 .collect();
567
568 if texts.is_empty() {
569 None
570 } else {
571 Some(texts.join(""))
572 }
573 }
574 _ => None,
575 }
576 }
577
578 pub fn as_assistant(&self) -> Option<&AssistantMessage> {
593 match self {
594 ClaudeOutput::Assistant(ass) => Some(ass),
595 _ => None,
596 }
597 }
598
599 pub fn as_result(&self) -> Option<&ResultMessage> {
615 match self {
616 ClaudeOutput::Result(res) => Some(res),
617 _ => None,
618 }
619 }
620
621 pub fn as_system(&self) -> Option<&SystemMessage> {
623 match self {
624 ClaudeOutput::System(sys) => Some(sys),
625 _ => None,
626 }
627 }
628
629 pub fn parse_json_tolerant(s: &str) -> Result<ClaudeOutput, ParseError> {
634 match Self::parse_json(s) {
636 Ok(output) => Ok(output),
637 Err(first_error) => {
638 if let Some(json_start) = s.find('{') {
640 let trimmed = &s[json_start..];
641 match Self::parse_json(trimmed) {
642 Ok(output) => Ok(output),
643 Err(_) => {
644 Err(first_error)
646 }
647 }
648 } else {
649 Err(first_error)
650 }
651 }
652 }
653 }
654
655 pub fn parse_json(s: &str) -> Result<ClaudeOutput, ParseError> {
657 let value: Value = serde_json::from_str(s).map_err(|e| ParseError {
659 raw_line: s.to_string(),
660 raw_json: None,
661 error_message: format!("Invalid JSON: {}", e),
662 })?;
663
664 serde_json::from_value::<ClaudeOutput>(value.clone()).map_err(|e| ParseError {
666 raw_line: s.to_string(),
667 raw_json: Some(value),
668 error_message: e.to_string(),
669 })
670 }
671}
672
673impl<'de> Deserialize<'de> for ClaudeOutput {
674 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
675 let value = Value::deserialize(deserializer)?;
676 let message_type = value
677 .get("type")
678 .and_then(|v| v.as_str())
679 .ok_or_else(|| serde::de::Error::missing_field("type"))?;
680 let mut payload = value.clone();
681 if let Some(obj) = payload.as_object_mut() {
682 obj.remove("type");
683 }
684
685 fn parse<T, E>(value: Value) -> Result<T, E>
686 where
687 T: serde::de::DeserializeOwned,
688 E: serde::de::Error,
689 {
690 serde_json::from_value(value).map_err(E::custom)
691 }
692
693 match message_type {
694 "system" => parse(payload).map(Self::System),
695 "user" => parse(payload).map(Self::User),
696 "assistant" => parse(payload).map(Self::Assistant),
697 "result" if value.get("subtype").is_some() => parse(payload).map(Self::Result),
698 "result" => parse(payload).map(Self::TranscriptResult),
699 "control_request" => parse(payload).map(Self::ControlRequest),
700 "control_response" => parse(payload).map(Self::ControlResponse),
701 "error" => parse(payload).map(Self::Error),
702 "rate_limit_event" => parse(payload).map(Self::RateLimitEvent),
703 "stream_event" => parse(payload).map(Self::StreamEvent),
704 "tool_progress" => parse(payload).map(Self::ToolProgress),
705 "command_lifecycle" => parse(payload).map(Self::CommandLifecycle),
706 "auth_status" => parse(payload).map(Self::AuthStatus),
707 "tool_use_summary" => parse(payload).map(Self::ToolUseSummary),
708 "prompt_suggestion" => parse(payload).map(Self::PromptSuggestion),
709 "conversation_reset" => parse(payload).map(Self::ConversationReset),
710 "progress" => parse(payload).map(Self::Progress),
711 "queue-operation" => parse(payload).map(Self::QueueOperation),
712 "pr-link" => parse(payload).map(Self::PrLink),
713 "file-history-snapshot" => parse(payload).map(Self::FileHistorySnapshot),
714 "summary" => parse(payload).map(Self::Summary),
715 "mode" => parse(payload).map(Self::Mode),
716 "permission-mode" => parse(payload).map(Self::PermissionMode),
717 "attachment" => parse(payload).map(Self::Attachment),
718 "ai-title" => parse(payload).map(Self::AiTitle),
719 "last-prompt" => parse(payload).map(Self::LastPrompt),
720 "started" => parse(payload).map(Self::Started),
721 other => Err(serde::de::Error::unknown_variant(
722 other,
723 &[
724 "system",
725 "user",
726 "assistant",
727 "result",
728 "control_request",
729 "control_response",
730 "error",
731 "rate_limit_event",
732 "stream_event",
733 "tool_progress",
734 "command_lifecycle",
735 "auth_status",
736 "tool_use_summary",
737 "prompt_suggestion",
738 "conversation_reset",
739 "progress",
740 "queue-operation",
741 "pr-link",
742 "file-history-snapshot",
743 "summary",
744 "mode",
745 "permission-mode",
746 "attachment",
747 "ai-title",
748 "last-prompt",
749 "started",
750 ],
751 )),
752 }
753 }
754}
755
756#[cfg(test)]
757mod tests {
758 use super::*;
759
760 #[test]
761 fn test_deserialize_assistant_message() {
762 let json = r#"{
763 "type": "assistant",
764 "message": {
765 "id": "msg_123",
766 "role": "assistant",
767 "model": "claude-3-sonnet",
768 "content": [{"type": "text", "text": "Hello! How can I help you?"}]
769 },
770 "session_id": "123"
771 }"#;
772
773 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
774 assert!(output.is_assistant_message());
775 }
776
777 #[test]
778 fn test_deserialize_new_top_level_message_types() {
779 let cases = [
780 (
781 r#"{"type":"stream_event","event":{"type":"content_block_delta"},"parent_tool_use_id":null,"uuid":"u1","session_id":"s1","ttft_ms":12}"#,
782 "stream_event",
783 ),
784 (
785 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"}"#,
786 "tool_progress",
787 ),
788 (
789 r#"{"type":"auth_status","isAuthenticating":true,"output":["login"],"uuid":"u3","session_id":"s3"}"#,
790 "auth_status",
791 ),
792 (
793 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"}"#,
794 "tool_use_summary",
795 ),
796 (
797 r#"{"type":"prompt_suggestion","suggestion":"Run tests","uuid":"u5","session_id":"s5"}"#,
798 "prompt_suggestion",
799 ),
800 (
801 r#"{"type":"conversation_reset","new_conversation_id":"new-session","uuid":"u6","session_id":"s6"}"#,
802 "conversation_reset",
803 ),
804 (
805 r#"{"type":"command_lifecycle","command_uuid":"cmd-1","state":"queued","uuid":"u7","session_id":"s7"}"#,
806 "command_lifecycle",
807 ),
808 ];
809
810 for (json, message_type) in cases {
811 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
812 assert_eq!(output.message_type(), message_type);
813 assert!(output.session_id().is_some());
814 }
815 }
816
817 #[test]
818 fn test_command_lifecycle_states_roundtrip() {
819 for state in ["queued", "started", "completed", "cancelled", "discarded"] {
820 let json = format!(
821 r#"{{"type":"command_lifecycle","command_uuid":"cmd-1","state":"{}","uuid":"u1","session_id":"s1"}}"#,
822 state
823 );
824 let output: ClaudeOutput = serde_json::from_str(&json).unwrap();
825 let ClaudeOutput::CommandLifecycle(msg) = &output else {
826 panic!("expected CommandLifecycle");
827 };
828 assert_eq!(msg.state.as_str(), state);
829 assert!(!matches!(msg.state, CommandLifecycleState::Unknown(_)));
830 assert_eq!(serde_json::to_string(&output).unwrap(), json);
831 }
832
833 let json = r#"{"type":"command_lifecycle","command_uuid":"cmd-2","state":"parked","uuid":"u2","session_id":"s2"}"#;
835 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
836 let ClaudeOutput::CommandLifecycle(msg) = &output else {
837 panic!("expected CommandLifecycle");
838 };
839 assert_eq!(
840 msg.state,
841 CommandLifecycleState::Unknown("parked".to_string())
842 );
843 assert_eq!(serde_json::to_string(&output).unwrap(), json);
844 }
845
846 #[test]
847 fn test_tool_progress_heartbeat_and_subagent_retry() {
848 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"}}"#;
849 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
850 let ClaudeOutput::ToolProgress(msg) = &output else {
851 panic!("expected ToolProgress");
852 };
853 assert_eq!(msg.heartbeat, Some(true));
854 assert_eq!(msg.subagent_type.as_deref(), Some("Explore"));
855 let retry = msg.subagent_retry.as_ref().unwrap();
856 assert_eq!(retry.attempt, 2);
857 assert_eq!(retry.error_status, Some(529));
858 assert_eq!(retry.error_category, "overloaded");
859
860 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"}}"#;
862 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
863 let ClaudeOutput::ToolProgress(msg) = &output else {
864 panic!("expected ToolProgress");
865 };
866 assert_eq!(msg.subagent_retry.as_ref().unwrap().error_status, None);
867 }
868
869 #[test]
870 fn test_deserialize_transcript_only_message_types() {
871 let cases = [
872 (
873 r#"{"type":"progress","sessionId":"s1","content":"delta"}"#,
874 "progress",
875 Some("s1"),
876 ),
877 (
878 r#"{"type":"queue-operation","sessionId":"s2","operation":"push"}"#,
879 "queue-operation",
880 Some("s2"),
881 ),
882 (
883 r#"{"type":"pr-link","sessionId":"s3","url":"https://example.invalid/pr"}"#,
884 "pr-link",
885 Some("s3"),
886 ),
887 (
888 r#"{"type":"file-history-snapshot","sessionId":"s4","files":[]}"#,
889 "file-history-snapshot",
890 Some("s4"),
891 ),
892 (r#"{"type":"summary","summary":"Done"}"#, "summary", None),
893 (
894 r#"{"type":"mode","mode":"normal","sessionId":"s5"}"#,
895 "mode",
896 Some("s5"),
897 ),
898 (
899 r#"{"type":"permission-mode","permissionMode":"default","sessionId":"s6"}"#,
900 "permission-mode",
901 Some("s6"),
902 ),
903 (
904 r#"{"type":"attachment","attachment":{"type":"task_reminder"},"sessionId":"s7"}"#,
905 "attachment",
906 Some("s7"),
907 ),
908 (
909 r#"{"type":"ai-title","aiTitle":"Title","sessionId":"s8"}"#,
910 "ai-title",
911 Some("s8"),
912 ),
913 (
914 r#"{"type":"last-prompt","lastPrompt":"prompt","sessionId":"s9"}"#,
915 "last-prompt",
916 Some("s9"),
917 ),
918 (
919 r#"{"type":"started","sessionId":"s10"}"#,
920 "started",
921 Some("s10"),
922 ),
923 (
924 r#"{"type":"result","key":"k","agentId":"a","result":{"ok":true}}"#,
925 "result",
926 None,
927 ),
928 ];
929
930 for (json, message_type, session_id) in cases {
931 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
932 assert_eq!(output.message_type(), message_type);
933 assert_eq!(output.session_id(), session_id);
934 }
935 }
936
937 #[test]
938 fn test_transcript_assistant_accepts_camel_case_session_id() {
939 let json = r#"{
940 "type": "assistant",
941 "sessionId": "camel-session",
942 "message": {
943 "id": "msg_123",
944 "role": "assistant",
945 "model": "claude-3-sonnet",
946 "content": [{"type": "text", "text": "Hello"}]
947 }
948 }"#;
949
950 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
951 assert_eq!(output.session_id(), Some("camel-session"));
952 }
953
954 #[test]
955 fn test_is_system_init() {
956 let init_json = r#"{
957 "type": "system",
958 "subtype": "init",
959 "session_id": "test-session"
960 }"#;
961 let output: ClaudeOutput = serde_json::from_str(init_json).unwrap();
962 assert!(output.is_system_init());
963
964 let status_json = r#"{
965 "type": "system",
966 "subtype": "status",
967 "session_id": "test-session"
968 }"#;
969 let output: ClaudeOutput = serde_json::from_str(status_json).unwrap();
970 assert!(!output.is_system_init());
971 }
972
973 #[test]
974 fn test_session_id() {
975 let result_json = r#"{
977 "type": "result",
978 "subtype": "success",
979 "is_error": false,
980 "duration_ms": 100,
981 "duration_api_ms": 200,
982 "num_turns": 1,
983 "session_id": "result-session",
984 "total_cost_usd": 0.01
985 }"#;
986 let output: ClaudeOutput = serde_json::from_str(result_json).unwrap();
987 assert_eq!(output.session_id(), Some("result-session"));
988
989 let assistant_json = r#"{
991 "type": "assistant",
992 "message": {
993 "id": "msg_1",
994 "role": "assistant",
995 "model": "claude-3",
996 "content": []
997 },
998 "session_id": "assistant-session"
999 }"#;
1000 let output: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
1001 assert_eq!(output.session_id(), Some("assistant-session"));
1002
1003 let system_json = r#"{
1005 "type": "system",
1006 "subtype": "init",
1007 "session_id": "system-session"
1008 }"#;
1009 let output: ClaudeOutput = serde_json::from_str(system_json).unwrap();
1010 assert_eq!(output.session_id(), Some("system-session"));
1011 }
1012
1013 #[test]
1014 fn test_as_tool_use() {
1015 let json = r#"{
1016 "type": "assistant",
1017 "message": {
1018 "id": "msg_1",
1019 "role": "assistant",
1020 "model": "claude-3",
1021 "content": [
1022 {"type": "text", "text": "Let me run that command."},
1023 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls -la"}},
1024 {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/test"}}
1025 ]
1026 },
1027 "session_id": "abc"
1028 }"#;
1029 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1030
1031 let bash = output.as_tool_use("Bash");
1033 assert!(bash.is_some());
1034 assert_eq!(bash.unwrap().id, "tu_1");
1035
1036 let read = output.as_tool_use("Read");
1038 assert!(read.is_some());
1039 assert_eq!(read.unwrap().id, "tu_2");
1040
1041 assert!(output.as_tool_use("Write").is_none());
1043
1044 let result_json = r#"{
1046 "type": "result",
1047 "subtype": "success",
1048 "is_error": false,
1049 "duration_ms": 100,
1050 "duration_api_ms": 200,
1051 "num_turns": 1,
1052 "session_id": "abc",
1053 "total_cost_usd": 0.01
1054 }"#;
1055 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1056 assert!(result.as_tool_use("Bash").is_none());
1057 }
1058
1059 #[test]
1060 fn test_tool_uses() {
1061 let json = r#"{
1062 "type": "assistant",
1063 "message": {
1064 "id": "msg_1",
1065 "role": "assistant",
1066 "model": "claude-3",
1067 "content": [
1068 {"type": "text", "text": "Running commands..."},
1069 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls"}},
1070 {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/a"}},
1071 {"type": "tool_use", "id": "tu_3", "name": "Write", "input": {"file_path": "/tmp/b", "content": "x"}}
1072 ]
1073 },
1074 "session_id": "abc"
1075 }"#;
1076 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1077
1078 let tools: Vec<_> = output.tool_uses().collect();
1079 assert_eq!(tools.len(), 3);
1080 assert_eq!(tools[0].name, "Bash");
1081 assert_eq!(tools[1].name, "Read");
1082 assert_eq!(tools[2].name, "Write");
1083 }
1084
1085 #[test]
1086 fn test_text_content() {
1087 let json = r#"{
1089 "type": "assistant",
1090 "message": {
1091 "id": "msg_1",
1092 "role": "assistant",
1093 "model": "claude-3",
1094 "content": [{"type": "text", "text": "Hello, world!"}]
1095 },
1096 "session_id": "abc"
1097 }"#;
1098 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1099 assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
1100
1101 let json = r#"{
1103 "type": "assistant",
1104 "message": {
1105 "id": "msg_1",
1106 "role": "assistant",
1107 "model": "claude-3",
1108 "content": [
1109 {"type": "text", "text": "Hello, "},
1110 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}},
1111 {"type": "text", "text": "world!"}
1112 ]
1113 },
1114 "session_id": "abc"
1115 }"#;
1116 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1117 assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
1118
1119 let json = r#"{
1121 "type": "assistant",
1122 "message": {
1123 "id": "msg_1",
1124 "role": "assistant",
1125 "model": "claude-3",
1126 "content": [{"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}}]
1127 },
1128 "session_id": "abc"
1129 }"#;
1130 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1131 assert_eq!(output.text_content(), None);
1132
1133 let json = r#"{
1135 "type": "result",
1136 "subtype": "success",
1137 "is_error": false,
1138 "duration_ms": 100,
1139 "duration_api_ms": 200,
1140 "num_turns": 1,
1141 "session_id": "abc",
1142 "total_cost_usd": 0.01
1143 }"#;
1144 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1145 assert_eq!(output.text_content(), None);
1146 }
1147
1148 #[test]
1149 fn test_as_assistant() {
1150 let json = r#"{
1151 "type": "assistant",
1152 "message": {
1153 "id": "msg_1",
1154 "role": "assistant",
1155 "model": "claude-sonnet-4",
1156 "content": []
1157 },
1158 "session_id": "abc"
1159 }"#;
1160 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1161
1162 let assistant = output.as_assistant();
1163 assert!(assistant.is_some());
1164 assert_eq!(assistant.unwrap().message.model, "claude-sonnet-4");
1165
1166 let result_json = r#"{
1168 "type": "result",
1169 "subtype": "success",
1170 "is_error": false,
1171 "duration_ms": 100,
1172 "duration_api_ms": 200,
1173 "num_turns": 1,
1174 "session_id": "abc",
1175 "total_cost_usd": 0.01
1176 }"#;
1177 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1178 assert!(result.as_assistant().is_none());
1179 }
1180
1181 #[test]
1182 fn test_as_result() {
1183 let json = r#"{
1184 "type": "result",
1185 "subtype": "success",
1186 "is_error": false,
1187 "duration_ms": 100,
1188 "duration_api_ms": 200,
1189 "num_turns": 5,
1190 "session_id": "abc",
1191 "total_cost_usd": 0.05
1192 }"#;
1193 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1194
1195 let result = output.as_result();
1196 assert!(result.is_some());
1197 assert_eq!(result.unwrap().num_turns, 5);
1198 assert_eq!(result.unwrap().total_cost_usd, 0.05);
1199
1200 let assistant_json = r#"{
1202 "type": "assistant",
1203 "message": {
1204 "id": "msg_1",
1205 "role": "assistant",
1206 "model": "claude-3",
1207 "content": []
1208 },
1209 "session_id": "abc"
1210 }"#;
1211 let assistant: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
1212 assert!(assistant.as_result().is_none());
1213 }
1214
1215 #[test]
1216 fn test_as_system() {
1217 let json = r#"{
1218 "type": "system",
1219 "subtype": "init",
1220 "session_id": "abc",
1221 "model": "claude-3"
1222 }"#;
1223 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1224
1225 let system = output.as_system();
1226 assert!(system.is_some());
1227 assert!(system.unwrap().is_init());
1228
1229 let result_json = r#"{
1231 "type": "result",
1232 "subtype": "success",
1233 "is_error": false,
1234 "duration_ms": 100,
1235 "duration_api_ms": 200,
1236 "num_turns": 1,
1237 "session_id": "abc",
1238 "total_cost_usd": 0.01
1239 }"#;
1240 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1241 assert!(result.as_system().is_none());
1242 }
1243}