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#[allow(clippy::large_enum_variant)]
13#[derive(Debug, Clone, Serialize)]
14#[serde(tag = "type", rename_all = "snake_case")]
15pub enum ClaudeOutput {
16 System(SystemMessage),
18
19 User(UserMessage),
21
22 Assistant(AssistantMessage),
24
25 Result(ResultMessage),
27
28 #[serde(rename = "result")]
30 TranscriptResult(TranscriptMessage),
31
32 ControlRequest(ControlRequest),
34
35 ControlResponse(ControlResponse),
37
38 Error(AnthropicError),
40
41 RateLimitEvent(RateLimitEvent),
43
44 StreamEvent(StreamEventMessage),
46
47 ToolProgress(ToolProgressMessage),
49
50 CommandLifecycle(CommandLifecycleMessage),
52
53 AuthStatus(AuthStatusMessage),
55
56 ToolUseSummary(ToolUseSummaryMessage),
58
59 PromptSuggestion(PromptSuggestionMessage),
61
62 ConversationReset(ConversationResetMessage),
64
65 Progress(TranscriptMessage),
67
68 #[serde(rename = "queue-operation")]
70 QueueOperation(TranscriptMessage),
71
72 #[serde(rename = "pr-link")]
74 PrLink(TranscriptMessage),
75
76 #[serde(rename = "file-history-snapshot")]
78 FileHistorySnapshot(TranscriptMessage),
79
80 Summary(TranscriptMessage),
82
83 Mode(TranscriptMessage),
85
86 #[serde(rename = "permission-mode")]
88 PermissionMode(TranscriptMessage),
89
90 Attachment(TranscriptMessage),
92
93 #[serde(rename = "ai-title")]
95 AiTitle(TranscriptMessage),
96
97 #[serde(rename = "last-prompt")]
99 LastPrompt(TranscriptMessage),
100
101 Started(TranscriptMessage),
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct StreamEventMessage {
107 pub event: Value,
108 pub parent_tool_use_id: Option<String>,
109 pub uuid: String,
110 pub session_id: String,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub ttft_ms: Option<u64>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub user_message_uuid: Option<String>,
121 #[serde(default, skip_serializing_if = "Vec::is_empty")]
126 pub user_message_uuids: Vec<String>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub resume_reason: Option<String>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub thinking_display: Option<String>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub abandoned_blocks: Option<AbandonedBlocks>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub api_message_id: Option<String>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub text_runs_joined: Option<bool>,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub struct AbandonedBlocks {
180 pub api_message_id: String,
184 pub from_block_index: u64,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct ToolProgressMessage {
192 pub tool_use_id: String,
193 pub tool_name: String,
194 pub parent_tool_use_id: Option<String>,
195 pub elapsed_time_seconds: f64,
196 #[serde(skip_serializing_if = "Option::is_none")]
197 pub task_id: Option<String>,
198 pub uuid: String,
199 pub session_id: String,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub heartbeat: Option<bool>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub subagent_type: Option<String>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub subagent_retry: Option<SubagentRetry>,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct SubagentRetry {
216 pub agent_id: String,
217 pub attempt: u64,
218 pub max_retries: u64,
219 pub retry_delay_ms: u64,
220 pub error_status: Option<u16>,
221 pub error_category: String,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct CommandLifecycleMessage {
233 pub command_uuid: String,
236 pub state: CommandLifecycleState,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub ran_ahead: Option<bool>,
243 pub uuid: String,
244 pub session_id: String,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Hash)]
249pub enum CommandLifecycleState {
250 Queued,
252 Started,
254 Completed,
256 Cancelled,
259 Discarded,
261 Unknown(String),
263}
264
265impl CommandLifecycleState {
266 pub fn as_str(&self) -> &str {
267 match self {
268 Self::Queued => "queued",
269 Self::Started => "started",
270 Self::Completed => "completed",
271 Self::Cancelled => "cancelled",
272 Self::Discarded => "discarded",
273 Self::Unknown(s) => s.as_str(),
274 }
275 }
276}
277
278impl std::fmt::Display for CommandLifecycleState {
279 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280 f.write_str(self.as_str())
281 }
282}
283
284impl From<&str> for CommandLifecycleState {
285 fn from(s: &str) -> Self {
286 match s {
287 "queued" => Self::Queued,
288 "started" => Self::Started,
289 "completed" => Self::Completed,
290 "cancelled" => Self::Cancelled,
291 "discarded" => Self::Discarded,
292 other => Self::Unknown(other.to_string()),
293 }
294 }
295}
296
297impl Serialize for CommandLifecycleState {
298 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
299 serializer.serialize_str(self.as_str())
300 }
301}
302
303impl<'de> Deserialize<'de> for CommandLifecycleState {
304 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
305 let s = String::deserialize(deserializer)?;
306 Ok(Self::from(s.as_str()))
307 }
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct AuthStatusMessage {
312 #[serde(rename = "isAuthenticating")]
313 pub is_authenticating: bool,
314 pub output: Vec<String>,
315 #[serde(skip_serializing_if = "Option::is_none")]
316 pub error: Option<String>,
317 pub uuid: String,
318 pub session_id: String,
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct ToolUseSummaryMessage {
323 pub summary: String,
324 pub preceding_tool_use_ids: Vec<String>,
325 pub uuid: String,
326 pub session_id: String,
327 #[serde(skip_serializing_if = "Option::is_none")]
328 pub timestamp: Option<String>,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct PromptSuggestionMessage {
333 pub suggestion: String,
334 pub uuid: String,
335 pub session_id: String,
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct ConversationResetMessage {
357 pub new_conversation_id: String,
361 pub uuid: String,
362 pub session_id: String,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub trigger: Option<ConversationResetTrigger>,
369 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub user_message_uuid: Option<String>,
378 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub timestamp: Option<String>,
384}
385
386#[derive(Debug, Clone, PartialEq, Eq, Hash)]
389pub enum ConversationResetTrigger {
390 Clear,
392 PlanModeExit,
394 FreshSession,
396 Onboarding,
398 Unknown(String),
400}
401
402impl ConversationResetTrigger {
403 pub fn as_str(&self) -> &str {
404 match self {
405 Self::Clear => "clear",
406 Self::PlanModeExit => "plan_mode_exit",
407 Self::FreshSession => "fresh_session",
408 Self::Onboarding => "onboarding",
409 Self::Unknown(s) => s.as_str(),
410 }
411 }
412}
413
414impl std::fmt::Display for ConversationResetTrigger {
415 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
416 f.write_str(self.as_str())
417 }
418}
419
420impl From<&str> for ConversationResetTrigger {
421 fn from(s: &str) -> Self {
422 match s {
423 "clear" => Self::Clear,
424 "plan_mode_exit" => Self::PlanModeExit,
425 "fresh_session" => Self::FreshSession,
426 "onboarding" => Self::Onboarding,
427 other => Self::Unknown(other.to_string()),
428 }
429 }
430}
431
432impl Serialize for ConversationResetTrigger {
433 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
434 serializer.serialize_str(self.as_str())
435 }
436}
437
438impl<'de> Deserialize<'de> for ConversationResetTrigger {
439 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
440 let s = String::deserialize(deserializer)?;
441 Ok(Self::from(s.as_str()))
442 }
443}
444
445#[derive(Debug, Clone, Serialize, Deserialize)]
452pub struct TranscriptMessage {
453 #[serde(flatten)]
454 pub data: Map<String, Value>,
455}
456
457impl ClaudeOutput {
458 pub fn message_type(&self) -> String {
460 match self {
461 ClaudeOutput::System(_) => "system".to_string(),
462 ClaudeOutput::User(_) => "user".to_string(),
463 ClaudeOutput::Assistant(_) => "assistant".to_string(),
464 ClaudeOutput::Result(_) => "result".to_string(),
465 ClaudeOutput::TranscriptResult(_) => "result".to_string(),
466 ClaudeOutput::ControlRequest(_) => "control_request".to_string(),
467 ClaudeOutput::ControlResponse(_) => "control_response".to_string(),
468 ClaudeOutput::Error(_) => "error".to_string(),
469 ClaudeOutput::RateLimitEvent(_) => "rate_limit_event".to_string(),
470 ClaudeOutput::StreamEvent(_) => "stream_event".to_string(),
471 ClaudeOutput::ToolProgress(_) => "tool_progress".to_string(),
472 ClaudeOutput::CommandLifecycle(_) => "command_lifecycle".to_string(),
473 ClaudeOutput::AuthStatus(_) => "auth_status".to_string(),
474 ClaudeOutput::ToolUseSummary(_) => "tool_use_summary".to_string(),
475 ClaudeOutput::PromptSuggestion(_) => "prompt_suggestion".to_string(),
476 ClaudeOutput::ConversationReset(_) => "conversation_reset".to_string(),
477 ClaudeOutput::Progress(_) => "progress".to_string(),
478 ClaudeOutput::QueueOperation(_) => "queue-operation".to_string(),
479 ClaudeOutput::PrLink(_) => "pr-link".to_string(),
480 ClaudeOutput::FileHistorySnapshot(_) => "file-history-snapshot".to_string(),
481 ClaudeOutput::Summary(_) => "summary".to_string(),
482 ClaudeOutput::Mode(_) => "mode".to_string(),
483 ClaudeOutput::PermissionMode(_) => "permission-mode".to_string(),
484 ClaudeOutput::Attachment(_) => "attachment".to_string(),
485 ClaudeOutput::AiTitle(_) => "ai-title".to_string(),
486 ClaudeOutput::LastPrompt(_) => "last-prompt".to_string(),
487 ClaudeOutput::Started(_) => "started".to_string(),
488 }
489 }
490
491 pub fn is_control_request(&self) -> bool {
493 matches!(self, ClaudeOutput::ControlRequest(_))
494 }
495
496 pub fn is_control_response(&self) -> bool {
498 matches!(self, ClaudeOutput::ControlResponse(_))
499 }
500
501 pub fn is_api_error(&self) -> bool {
503 matches!(self, ClaudeOutput::Error(_))
504 }
505
506 pub fn as_control_request(&self) -> Option<&ControlRequest> {
508 match self {
509 ClaudeOutput::ControlRequest(req) => Some(req),
510 _ => None,
511 }
512 }
513
514 pub fn as_anthropic_error(&self) -> Option<&AnthropicError> {
530 match self {
531 ClaudeOutput::Error(err) => Some(err),
532 _ => None,
533 }
534 }
535
536 pub fn is_rate_limit_event(&self) -> bool {
538 matches!(self, ClaudeOutput::RateLimitEvent(_))
539 }
540
541 pub fn as_rate_limit_event(&self) -> Option<&RateLimitEvent> {
543 match self {
544 ClaudeOutput::RateLimitEvent(evt) => Some(evt),
545 _ => None,
546 }
547 }
548
549 pub fn is_error(&self) -> bool {
551 matches!(self, ClaudeOutput::Result(r) if r.is_error)
552 }
553
554 pub fn is_assistant_message(&self) -> bool {
556 matches!(self, ClaudeOutput::Assistant(_))
557 }
558
559 pub fn is_system_message(&self) -> bool {
561 matches!(self, ClaudeOutput::System(_))
562 }
563
564 pub fn is_system_init(&self) -> bool {
575 matches!(self, ClaudeOutput::System(sys) if sys.is_init())
576 }
577
578 pub fn session_id(&self) -> Option<&str> {
594 match self {
595 ClaudeOutput::System(sys) => sys
596 .data
597 .get("session_id")
598 .or_else(|| sys.data.get("sessionId"))
599 .and_then(|v| v.as_str()),
600 ClaudeOutput::Assistant(ass) => Some(&ass.session_id),
601 ClaudeOutput::Result(res) => Some(&res.session_id),
602 ClaudeOutput::TranscriptResult(msg) => msg
603 .data
604 .get("session_id")
605 .or_else(|| msg.data.get("sessionId"))
606 .and_then(|v| v.as_str()),
607 ClaudeOutput::User(_) => None,
608 ClaudeOutput::ControlRequest(_) => None,
609 ClaudeOutput::ControlResponse(_) => None,
610 ClaudeOutput::Error(_) => None,
611 ClaudeOutput::RateLimitEvent(evt) => Some(&evt.session_id),
612 ClaudeOutput::StreamEvent(msg) => Some(&msg.session_id),
613 ClaudeOutput::ToolProgress(msg) => Some(&msg.session_id),
614 ClaudeOutput::CommandLifecycle(msg) => Some(&msg.session_id),
615 ClaudeOutput::AuthStatus(msg) => Some(&msg.session_id),
616 ClaudeOutput::ToolUseSummary(msg) => Some(&msg.session_id),
617 ClaudeOutput::PromptSuggestion(msg) => Some(&msg.session_id),
618 ClaudeOutput::ConversationReset(msg) => Some(&msg.session_id),
619 ClaudeOutput::Progress(msg)
620 | ClaudeOutput::QueueOperation(msg)
621 | ClaudeOutput::PrLink(msg)
622 | ClaudeOutput::FileHistorySnapshot(msg)
623 | ClaudeOutput::Summary(msg)
624 | ClaudeOutput::Mode(msg)
625 | ClaudeOutput::PermissionMode(msg)
626 | ClaudeOutput::Attachment(msg)
627 | ClaudeOutput::AiTitle(msg)
628 | ClaudeOutput::LastPrompt(msg)
629 | ClaudeOutput::Started(msg) => msg
630 .data
631 .get("session_id")
632 .or_else(|| msg.data.get("sessionId"))
633 .and_then(|v| v.as_str()),
634 }
635 }
636
637 pub fn as_tool_use(&self, tool_name: &str) -> Option<&ToolUseBlock> {
656 match self {
657 ClaudeOutput::Assistant(ass) => {
658 ass.message.content.iter().find_map(|block| match block {
659 ContentBlock::ToolUse(tu) if tu.name == tool_name => Some(tu),
660 _ => None,
661 })
662 }
663 _ => None,
664 }
665 }
666
667 pub fn tool_uses(&self) -> impl Iterator<Item = &ToolUseBlock> {
687 let content = match self {
688 ClaudeOutput::Assistant(ass) => Some(&ass.message.content),
689 _ => None,
690 };
691
692 content
693 .into_iter()
694 .flat_map(|c| c.iter())
695 .filter_map(|block| match block {
696 ContentBlock::ToolUse(tu) => Some(tu),
697 _ => None,
698 })
699 }
700
701 pub fn text_content(&self) -> Option<String> {
717 match self {
718 ClaudeOutput::Assistant(ass) => {
719 let texts: Vec<&str> = ass
720 .message
721 .content
722 .iter()
723 .filter_map(|block| match block {
724 ContentBlock::Text(t) => Some(t.text.as_str()),
725 _ => None,
726 })
727 .collect();
728
729 if texts.is_empty() {
730 None
731 } else {
732 Some(texts.join(""))
733 }
734 }
735 _ => None,
736 }
737 }
738
739 pub fn as_assistant(&self) -> Option<&AssistantMessage> {
754 match self {
755 ClaudeOutput::Assistant(ass) => Some(ass),
756 _ => None,
757 }
758 }
759
760 pub fn as_result(&self) -> Option<&ResultMessage> {
776 match self {
777 ClaudeOutput::Result(res) => Some(res),
778 _ => None,
779 }
780 }
781
782 pub fn as_system(&self) -> Option<&SystemMessage> {
784 match self {
785 ClaudeOutput::System(sys) => Some(sys),
786 _ => None,
787 }
788 }
789
790 pub fn parse_json_tolerant(s: &str) -> Result<ClaudeOutput, ParseError> {
795 match Self::parse_json(s) {
797 Ok(output) => Ok(output),
798 Err(first_error) => {
799 if let Some(json_start) = s.find('{') {
801 let trimmed = &s[json_start..];
802 match Self::parse_json(trimmed) {
803 Ok(output) => Ok(output),
804 Err(_) => {
805 Err(first_error)
807 }
808 }
809 } else {
810 Err(first_error)
811 }
812 }
813 }
814 }
815
816 pub fn parse_json(s: &str) -> Result<ClaudeOutput, ParseError> {
818 let value: Value = serde_json::from_str(s).map_err(|e| ParseError {
820 raw_line: s.to_string(),
821 raw_json: None,
822 error_message: format!("Invalid JSON: {}", e),
823 })?;
824
825 serde_json::from_value::<ClaudeOutput>(value.clone()).map_err(|e| ParseError {
827 raw_line: s.to_string(),
828 raw_json: Some(value),
829 error_message: e.to_string(),
830 })
831 }
832}
833
834impl<'de> Deserialize<'de> for ClaudeOutput {
835 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
836 let value = Value::deserialize(deserializer)?;
837 let message_type = value
838 .get("type")
839 .and_then(|v| v.as_str())
840 .ok_or_else(|| serde::de::Error::missing_field("type"))?;
841 let mut payload = value.clone();
842 if let Some(obj) = payload.as_object_mut() {
843 obj.remove("type");
844 }
845
846 fn parse<T, E>(value: Value) -> Result<T, E>
847 where
848 T: serde::de::DeserializeOwned,
849 E: serde::de::Error,
850 {
851 serde_json::from_value(value).map_err(E::custom)
852 }
853
854 match message_type {
855 "system" => parse(payload).map(Self::System),
856 "user" => parse(payload).map(Self::User),
857 "assistant" => parse(payload).map(Self::Assistant),
858 "result" if value.get("subtype").is_some() => parse(payload).map(Self::Result),
859 "result" => parse(payload).map(Self::TranscriptResult),
860 "control_request" => parse(payload).map(Self::ControlRequest),
861 "control_response" => parse(payload).map(Self::ControlResponse),
862 "error" => parse(payload).map(Self::Error),
863 "rate_limit_event" => parse(payload).map(Self::RateLimitEvent),
864 "stream_event" => parse(payload).map(Self::StreamEvent),
865 "tool_progress" => parse(payload).map(Self::ToolProgress),
866 "command_lifecycle" => parse(payload).map(Self::CommandLifecycle),
867 "auth_status" => parse(payload).map(Self::AuthStatus),
868 "tool_use_summary" => parse(payload).map(Self::ToolUseSummary),
869 "prompt_suggestion" => parse(payload).map(Self::PromptSuggestion),
870 "conversation_reset" => parse(payload).map(Self::ConversationReset),
871 "progress" => parse(payload).map(Self::Progress),
872 "queue-operation" => parse(payload).map(Self::QueueOperation),
873 "pr-link" => parse(payload).map(Self::PrLink),
874 "file-history-snapshot" => parse(payload).map(Self::FileHistorySnapshot),
875 "summary" => parse(payload).map(Self::Summary),
876 "mode" => parse(payload).map(Self::Mode),
877 "permission-mode" => parse(payload).map(Self::PermissionMode),
878 "attachment" => parse(payload).map(Self::Attachment),
879 "ai-title" => parse(payload).map(Self::AiTitle),
880 "last-prompt" => parse(payload).map(Self::LastPrompt),
881 "started" => parse(payload).map(Self::Started),
882 other => Err(serde::de::Error::unknown_variant(
883 other,
884 &[
885 "system",
886 "user",
887 "assistant",
888 "result",
889 "control_request",
890 "control_response",
891 "error",
892 "rate_limit_event",
893 "stream_event",
894 "tool_progress",
895 "command_lifecycle",
896 "auth_status",
897 "tool_use_summary",
898 "prompt_suggestion",
899 "conversation_reset",
900 "progress",
901 "queue-operation",
902 "pr-link",
903 "file-history-snapshot",
904 "summary",
905 "mode",
906 "permission-mode",
907 "attachment",
908 "ai-title",
909 "last-prompt",
910 "started",
911 ],
912 )),
913 }
914 }
915}
916
917#[cfg(test)]
918mod tests {
919 use super::*;
920
921 #[test]
922 fn test_deserialize_assistant_message() {
923 let json = r#"{
924 "type": "assistant",
925 "message": {
926 "id": "msg_123",
927 "role": "assistant",
928 "model": "claude-3-sonnet",
929 "content": [{"type": "text", "text": "Hello! How can I help you?"}]
930 },
931 "session_id": "123"
932 }"#;
933
934 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
935 assert!(output.is_assistant_message());
936 }
937
938 #[test]
939 fn test_deserialize_new_top_level_message_types() {
940 let cases = [
941 (
942 r#"{"type":"stream_event","event":{"type":"content_block_delta"},"parent_tool_use_id":null,"uuid":"u1","session_id":"s1","ttft_ms":12}"#,
943 "stream_event",
944 ),
945 (
946 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"}"#,
947 "tool_progress",
948 ),
949 (
950 r#"{"type":"auth_status","isAuthenticating":true,"output":["login"],"uuid":"u3","session_id":"s3"}"#,
951 "auth_status",
952 ),
953 (
954 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"}"#,
955 "tool_use_summary",
956 ),
957 (
958 r#"{"type":"prompt_suggestion","suggestion":"Run tests","uuid":"u5","session_id":"s5"}"#,
959 "prompt_suggestion",
960 ),
961 (
962 r#"{"type":"conversation_reset","new_conversation_id":"new-session","uuid":"u6","session_id":"s6"}"#,
963 "conversation_reset",
964 ),
965 (
966 r#"{"type":"command_lifecycle","command_uuid":"cmd-1","state":"queued","uuid":"u7","session_id":"s7"}"#,
967 "command_lifecycle",
968 ),
969 ];
970
971 for (json, message_type) in cases {
972 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
973 assert_eq!(output.message_type(), message_type);
974 assert!(output.session_id().is_some());
975 }
976 }
977
978 #[test]
979 fn test_command_lifecycle_states_roundtrip() {
980 for state in ["queued", "started", "completed", "cancelled", "discarded"] {
981 let json = format!(
982 r#"{{"type":"command_lifecycle","command_uuid":"cmd-1","state":"{}","uuid":"u1","session_id":"s1"}}"#,
983 state
984 );
985 let output: ClaudeOutput = serde_json::from_str(&json).unwrap();
986 let ClaudeOutput::CommandLifecycle(msg) = &output else {
987 panic!("expected CommandLifecycle");
988 };
989 assert_eq!(msg.state.as_str(), state);
990 assert!(!matches!(msg.state, CommandLifecycleState::Unknown(_)));
991 assert_eq!(serde_json::to_string(&output).unwrap(), json);
992 }
993
994 let json = r#"{"type":"command_lifecycle","command_uuid":"cmd-2","state":"parked","uuid":"u2","session_id":"s2"}"#;
996 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
997 let ClaudeOutput::CommandLifecycle(msg) = &output else {
998 panic!("expected CommandLifecycle");
999 };
1000 assert_eq!(
1001 msg.state,
1002 CommandLifecycleState::Unknown("parked".to_string())
1003 );
1004 assert_eq!(serde_json::to_string(&output).unwrap(), json);
1005 }
1006
1007 #[test]
1008 fn test_tool_progress_heartbeat_and_subagent_retry() {
1009 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"}}"#;
1010 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1011 let ClaudeOutput::ToolProgress(msg) = &output else {
1012 panic!("expected ToolProgress");
1013 };
1014 assert_eq!(msg.heartbeat, Some(true));
1015 assert_eq!(msg.subagent_type.as_deref(), Some("Explore"));
1016 let retry = msg.subagent_retry.as_ref().unwrap();
1017 assert_eq!(retry.attempt, 2);
1018 assert_eq!(retry.error_status, Some(529));
1019 assert_eq!(retry.error_category, "overloaded");
1020
1021 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"}}"#;
1023 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1024 let ClaudeOutput::ToolProgress(msg) = &output else {
1025 panic!("expected ToolProgress");
1026 };
1027 assert_eq!(msg.subagent_retry.as_ref().unwrap().error_status, None);
1028 }
1029
1030 #[test]
1031 fn test_deserialize_transcript_only_message_types() {
1032 let cases = [
1033 (
1034 r#"{"type":"progress","sessionId":"s1","content":"delta"}"#,
1035 "progress",
1036 Some("s1"),
1037 ),
1038 (
1039 r#"{"type":"queue-operation","sessionId":"s2","operation":"push"}"#,
1040 "queue-operation",
1041 Some("s2"),
1042 ),
1043 (
1044 r#"{"type":"pr-link","sessionId":"s3","url":"https://example.invalid/pr"}"#,
1045 "pr-link",
1046 Some("s3"),
1047 ),
1048 (
1049 r#"{"type":"file-history-snapshot","sessionId":"s4","files":[]}"#,
1050 "file-history-snapshot",
1051 Some("s4"),
1052 ),
1053 (r#"{"type":"summary","summary":"Done"}"#, "summary", None),
1054 (
1055 r#"{"type":"mode","mode":"normal","sessionId":"s5"}"#,
1056 "mode",
1057 Some("s5"),
1058 ),
1059 (
1060 r#"{"type":"permission-mode","permissionMode":"default","sessionId":"s6"}"#,
1061 "permission-mode",
1062 Some("s6"),
1063 ),
1064 (
1065 r#"{"type":"attachment","attachment":{"type":"task_reminder"},"sessionId":"s7"}"#,
1066 "attachment",
1067 Some("s7"),
1068 ),
1069 (
1070 r#"{"type":"ai-title","aiTitle":"Title","sessionId":"s8"}"#,
1071 "ai-title",
1072 Some("s8"),
1073 ),
1074 (
1075 r#"{"type":"last-prompt","lastPrompt":"prompt","sessionId":"s9"}"#,
1076 "last-prompt",
1077 Some("s9"),
1078 ),
1079 (
1080 r#"{"type":"started","sessionId":"s10"}"#,
1081 "started",
1082 Some("s10"),
1083 ),
1084 (
1085 r#"{"type":"result","key":"k","agentId":"a","result":{"ok":true}}"#,
1086 "result",
1087 None,
1088 ),
1089 ];
1090
1091 for (json, message_type, session_id) in cases {
1092 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1093 assert_eq!(output.message_type(), message_type);
1094 assert_eq!(output.session_id(), session_id);
1095 }
1096 }
1097
1098 #[test]
1099 fn test_transcript_assistant_accepts_camel_case_session_id() {
1100 let json = r#"{
1101 "type": "assistant",
1102 "sessionId": "camel-session",
1103 "message": {
1104 "id": "msg_123",
1105 "role": "assistant",
1106 "model": "claude-3-sonnet",
1107 "content": [{"type": "text", "text": "Hello"}]
1108 }
1109 }"#;
1110
1111 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1112 assert_eq!(output.session_id(), Some("camel-session"));
1113 }
1114
1115 #[test]
1116 fn test_is_system_init() {
1117 let init_json = r#"{
1118 "type": "system",
1119 "subtype": "init",
1120 "session_id": "test-session"
1121 }"#;
1122 let output: ClaudeOutput = serde_json::from_str(init_json).unwrap();
1123 assert!(output.is_system_init());
1124
1125 let status_json = r#"{
1126 "type": "system",
1127 "subtype": "status",
1128 "session_id": "test-session"
1129 }"#;
1130 let output: ClaudeOutput = serde_json::from_str(status_json).unwrap();
1131 assert!(!output.is_system_init());
1132 }
1133
1134 #[test]
1135 fn test_session_id() {
1136 let result_json = r#"{
1138 "type": "result",
1139 "subtype": "success",
1140 "is_error": false,
1141 "duration_ms": 100,
1142 "duration_api_ms": 200,
1143 "num_turns": 1,
1144 "session_id": "result-session",
1145 "total_cost_usd": 0.01
1146 }"#;
1147 let output: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1148 assert_eq!(output.session_id(), Some("result-session"));
1149
1150 let assistant_json = r#"{
1152 "type": "assistant",
1153 "message": {
1154 "id": "msg_1",
1155 "role": "assistant",
1156 "model": "claude-3",
1157 "content": []
1158 },
1159 "session_id": "assistant-session"
1160 }"#;
1161 let output: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
1162 assert_eq!(output.session_id(), Some("assistant-session"));
1163
1164 let system_json = r#"{
1166 "type": "system",
1167 "subtype": "init",
1168 "session_id": "system-session"
1169 }"#;
1170 let output: ClaudeOutput = serde_json::from_str(system_json).unwrap();
1171 assert_eq!(output.session_id(), Some("system-session"));
1172 }
1173
1174 #[test]
1175 fn test_as_tool_use() {
1176 let json = r#"{
1177 "type": "assistant",
1178 "message": {
1179 "id": "msg_1",
1180 "role": "assistant",
1181 "model": "claude-3",
1182 "content": [
1183 {"type": "text", "text": "Let me run that command."},
1184 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls -la"}},
1185 {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/test"}}
1186 ]
1187 },
1188 "session_id": "abc"
1189 }"#;
1190 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1191
1192 let bash = output.as_tool_use("Bash");
1194 assert!(bash.is_some());
1195 assert_eq!(bash.unwrap().id, "tu_1");
1196
1197 let read = output.as_tool_use("Read");
1199 assert!(read.is_some());
1200 assert_eq!(read.unwrap().id, "tu_2");
1201
1202 assert!(output.as_tool_use("Write").is_none());
1204
1205 let result_json = r#"{
1207 "type": "result",
1208 "subtype": "success",
1209 "is_error": false,
1210 "duration_ms": 100,
1211 "duration_api_ms": 200,
1212 "num_turns": 1,
1213 "session_id": "abc",
1214 "total_cost_usd": 0.01
1215 }"#;
1216 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1217 assert!(result.as_tool_use("Bash").is_none());
1218 }
1219
1220 #[test]
1221 fn test_tool_uses() {
1222 let json = r#"{
1223 "type": "assistant",
1224 "message": {
1225 "id": "msg_1",
1226 "role": "assistant",
1227 "model": "claude-3",
1228 "content": [
1229 {"type": "text", "text": "Running commands..."},
1230 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls"}},
1231 {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/a"}},
1232 {"type": "tool_use", "id": "tu_3", "name": "Write", "input": {"file_path": "/tmp/b", "content": "x"}}
1233 ]
1234 },
1235 "session_id": "abc"
1236 }"#;
1237 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1238
1239 let tools: Vec<_> = output.tool_uses().collect();
1240 assert_eq!(tools.len(), 3);
1241 assert_eq!(tools[0].name, "Bash");
1242 assert_eq!(tools[1].name, "Read");
1243 assert_eq!(tools[2].name, "Write");
1244 }
1245
1246 #[test]
1247 fn test_text_content() {
1248 let json = r#"{
1250 "type": "assistant",
1251 "message": {
1252 "id": "msg_1",
1253 "role": "assistant",
1254 "model": "claude-3",
1255 "content": [{"type": "text", "text": "Hello, world!"}]
1256 },
1257 "session_id": "abc"
1258 }"#;
1259 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1260 assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
1261
1262 let json = r#"{
1264 "type": "assistant",
1265 "message": {
1266 "id": "msg_1",
1267 "role": "assistant",
1268 "model": "claude-3",
1269 "content": [
1270 {"type": "text", "text": "Hello, "},
1271 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}},
1272 {"type": "text", "text": "world!"}
1273 ]
1274 },
1275 "session_id": "abc"
1276 }"#;
1277 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1278 assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
1279
1280 let json = r#"{
1282 "type": "assistant",
1283 "message": {
1284 "id": "msg_1",
1285 "role": "assistant",
1286 "model": "claude-3",
1287 "content": [{"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}}]
1288 },
1289 "session_id": "abc"
1290 }"#;
1291 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1292 assert_eq!(output.text_content(), None);
1293
1294 let json = r#"{
1296 "type": "result",
1297 "subtype": "success",
1298 "is_error": false,
1299 "duration_ms": 100,
1300 "duration_api_ms": 200,
1301 "num_turns": 1,
1302 "session_id": "abc",
1303 "total_cost_usd": 0.01
1304 }"#;
1305 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1306 assert_eq!(output.text_content(), None);
1307 }
1308
1309 #[test]
1310 fn test_as_assistant() {
1311 let json = r#"{
1312 "type": "assistant",
1313 "message": {
1314 "id": "msg_1",
1315 "role": "assistant",
1316 "model": "claude-sonnet-4",
1317 "content": []
1318 },
1319 "session_id": "abc"
1320 }"#;
1321 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1322
1323 let assistant = output.as_assistant();
1324 assert!(assistant.is_some());
1325 assert_eq!(assistant.unwrap().message.model, "claude-sonnet-4");
1326
1327 let result_json = r#"{
1329 "type": "result",
1330 "subtype": "success",
1331 "is_error": false,
1332 "duration_ms": 100,
1333 "duration_api_ms": 200,
1334 "num_turns": 1,
1335 "session_id": "abc",
1336 "total_cost_usd": 0.01
1337 }"#;
1338 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1339 assert!(result.as_assistant().is_none());
1340 }
1341
1342 #[test]
1343 fn test_as_result() {
1344 let json = r#"{
1345 "type": "result",
1346 "subtype": "success",
1347 "is_error": false,
1348 "duration_ms": 100,
1349 "duration_api_ms": 200,
1350 "num_turns": 5,
1351 "session_id": "abc",
1352 "total_cost_usd": 0.05
1353 }"#;
1354 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1355
1356 let result = output.as_result();
1357 assert!(result.is_some());
1358 assert_eq!(result.unwrap().num_turns, 5);
1359 assert_eq!(result.unwrap().total_cost_usd, 0.05);
1360
1361 let assistant_json = r#"{
1363 "type": "assistant",
1364 "message": {
1365 "id": "msg_1",
1366 "role": "assistant",
1367 "model": "claude-3",
1368 "content": []
1369 },
1370 "session_id": "abc"
1371 }"#;
1372 let assistant: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
1373 assert!(assistant.as_result().is_none());
1374 }
1375
1376 #[test]
1377 fn test_as_system() {
1378 let json = r#"{
1379 "type": "system",
1380 "subtype": "init",
1381 "session_id": "abc",
1382 "model": "claude-3"
1383 }"#;
1384 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1385
1386 let system = output.as_system();
1387 assert!(system.is_some());
1388 assert!(system.unwrap().is_init());
1389
1390 let result_json = r#"{
1392 "type": "result",
1393 "subtype": "success",
1394 "is_error": false,
1395 "duration_ms": 100,
1396 "duration_api_ms": 200,
1397 "num_turns": 1,
1398 "session_id": "abc",
1399 "total_cost_usd": 0.01
1400 }"#;
1401 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1402 assert!(result.as_system().is_none());
1403 }
1404}