1use serde::{Deserialize, Deserializer, Serialize};
2use serde_json::{Map, Value};
3
4use super::content_blocks::{ContentBlock, ToolUseBlock};
5use super::control::{ControlCancelRequest, ControlRequest, ControlResponse};
6use super::errors::{AnthropicError, ParseError};
7use super::message_types::{AssistantMessage, SystemMessage, UserMessage};
8use super::rate_limit::RateLimitEvent;
9use super::result::ResultMessage;
10
11#[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 ControlCancelRequest(ControlCancelRequest),
41
42 Error(AnthropicError),
44
45 RateLimitEvent(RateLimitEvent),
47
48 StreamEvent(StreamEventMessage),
50
51 ToolProgress(ToolProgressMessage),
53
54 CommandLifecycle(CommandLifecycleMessage),
56
57 AuthStatus(AuthStatusMessage),
59
60 ToolUseSummary(ToolUseSummaryMessage),
62
63 PromptSuggestion(PromptSuggestionMessage),
65
66 ConversationReset(ConversationResetMessage),
68
69 Progress(TranscriptMessage),
71
72 #[serde(rename = "queue-operation")]
74 QueueOperation(TranscriptMessage),
75
76 #[serde(rename = "pr-link")]
78 PrLink(TranscriptMessage),
79
80 #[serde(rename = "file-history-snapshot")]
82 FileHistorySnapshot(TranscriptMessage),
83
84 Summary(TranscriptMessage),
86
87 Mode(TranscriptMessage),
89
90 #[serde(rename = "permission-mode")]
92 PermissionMode(TranscriptMessage),
93
94 Attachment(TranscriptMessage),
96
97 #[serde(rename = "ai-title")]
99 AiTitle(TranscriptMessage),
100
101 #[serde(rename = "last-prompt")]
103 LastPrompt(TranscriptMessage),
104
105 Started(TranscriptMessage),
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct StreamEventMessage {
111 pub event: Value,
112 pub parent_tool_use_id: Option<String>,
113 pub uuid: String,
114 pub session_id: String,
115 #[serde(skip_serializing_if = "Option::is_none")]
116 pub ttft_ms: Option<u64>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub user_message_uuid: Option<String>,
125 #[serde(default, skip_serializing_if = "Vec::is_empty")]
130 pub user_message_uuids: Vec<String>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub resume_reason: Option<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub thinking_display: Option<String>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub abandoned_blocks: Option<AbandonedBlocks>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub api_message_id: Option<String>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub text_runs_joined: Option<bool>,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183pub struct AbandonedBlocks {
184 pub api_message_id: String,
188 pub from_block_index: u64,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct ToolProgressMessage {
196 pub tool_use_id: String,
197 pub tool_name: String,
198 pub parent_tool_use_id: Option<String>,
199 pub elapsed_time_seconds: f64,
200 #[serde(skip_serializing_if = "Option::is_none")]
201 pub task_id: Option<String>,
202 pub uuid: String,
203 pub session_id: String,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub heartbeat: Option<bool>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub subagent_type: Option<String>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub subagent_retry: Option<SubagentRetry>,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct SubagentRetry {
220 pub agent_id: String,
221 pub attempt: u64,
222 pub max_retries: u64,
223 pub retry_delay_ms: u64,
224 pub error_status: Option<u16>,
225 pub error_category: String,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct CommandLifecycleMessage {
237 pub command_uuid: String,
240 pub state: CommandLifecycleState,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub ran_ahead: Option<bool>,
247 pub uuid: String,
248 pub session_id: String,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Hash)]
253pub enum CommandLifecycleState {
254 Queued,
256 Started,
258 Completed,
260 Cancelled,
263 Discarded,
265 Unknown(String),
267}
268
269impl CommandLifecycleState {
270 pub fn as_str(&self) -> &str {
271 match self {
272 Self::Queued => "queued",
273 Self::Started => "started",
274 Self::Completed => "completed",
275 Self::Cancelled => "cancelled",
276 Self::Discarded => "discarded",
277 Self::Unknown(s) => s.as_str(),
278 }
279 }
280}
281
282impl std::fmt::Display for CommandLifecycleState {
283 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284 f.write_str(self.as_str())
285 }
286}
287
288impl From<&str> for CommandLifecycleState {
289 fn from(s: &str) -> Self {
290 match s {
291 "queued" => Self::Queued,
292 "started" => Self::Started,
293 "completed" => Self::Completed,
294 "cancelled" => Self::Cancelled,
295 "discarded" => Self::Discarded,
296 other => Self::Unknown(other.to_string()),
297 }
298 }
299}
300
301impl Serialize for CommandLifecycleState {
302 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
303 serializer.serialize_str(self.as_str())
304 }
305}
306
307impl<'de> Deserialize<'de> for CommandLifecycleState {
308 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
309 let s = String::deserialize(deserializer)?;
310 Ok(Self::from(s.as_str()))
311 }
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct AuthStatusMessage {
316 #[serde(rename = "isAuthenticating")]
317 pub is_authenticating: bool,
318 pub output: Vec<String>,
319 #[serde(skip_serializing_if = "Option::is_none")]
320 pub error: Option<String>,
321 pub uuid: String,
322 pub session_id: String,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct ToolUseSummaryMessage {
327 pub summary: String,
328 pub preceding_tool_use_ids: Vec<String>,
329 pub uuid: String,
330 pub session_id: String,
331 #[serde(skip_serializing_if = "Option::is_none")]
332 pub timestamp: Option<String>,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct PromptSuggestionMessage {
337 pub suggestion: String,
338 pub uuid: String,
339 pub session_id: String,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
360pub struct ConversationResetMessage {
361 pub new_conversation_id: String,
365 pub uuid: String,
366 pub session_id: String,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub trigger: Option<ConversationResetTrigger>,
373 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub user_message_uuid: Option<String>,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub timestamp: Option<String>,
388}
389
390#[derive(Debug, Clone, PartialEq, Eq, Hash)]
393pub enum ConversationResetTrigger {
394 Clear,
396 PlanModeExit,
398 FreshSession,
400 Onboarding,
402 Unknown(String),
404}
405
406impl ConversationResetTrigger {
407 pub fn as_str(&self) -> &str {
408 match self {
409 Self::Clear => "clear",
410 Self::PlanModeExit => "plan_mode_exit",
411 Self::FreshSession => "fresh_session",
412 Self::Onboarding => "onboarding",
413 Self::Unknown(s) => s.as_str(),
414 }
415 }
416}
417
418impl std::fmt::Display for ConversationResetTrigger {
419 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
420 f.write_str(self.as_str())
421 }
422}
423
424impl From<&str> for ConversationResetTrigger {
425 fn from(s: &str) -> Self {
426 match s {
427 "clear" => Self::Clear,
428 "plan_mode_exit" => Self::PlanModeExit,
429 "fresh_session" => Self::FreshSession,
430 "onboarding" => Self::Onboarding,
431 other => Self::Unknown(other.to_string()),
432 }
433 }
434}
435
436impl Serialize for ConversationResetTrigger {
437 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
438 serializer.serialize_str(self.as_str())
439 }
440}
441
442impl<'de> Deserialize<'de> for ConversationResetTrigger {
443 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
444 let s = String::deserialize(deserializer)?;
445 Ok(Self::from(s.as_str()))
446 }
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
456pub struct TranscriptMessage {
457 #[serde(flatten)]
458 pub data: Map<String, Value>,
459}
460
461impl ClaudeOutput {
462 pub fn message_type(&self) -> String {
464 match self {
465 ClaudeOutput::System(_) => "system".to_string(),
466 ClaudeOutput::User(_) => "user".to_string(),
467 ClaudeOutput::Assistant(_) => "assistant".to_string(),
468 ClaudeOutput::Result(_) => "result".to_string(),
469 ClaudeOutput::TranscriptResult(_) => "result".to_string(),
470 ClaudeOutput::ControlRequest(_) => "control_request".to_string(),
471 ClaudeOutput::ControlResponse(_) => "control_response".to_string(),
472 ClaudeOutput::ControlCancelRequest(_) => "control_cancel_request".to_string(),
473 ClaudeOutput::Error(_) => "error".to_string(),
474 ClaudeOutput::RateLimitEvent(_) => "rate_limit_event".to_string(),
475 ClaudeOutput::StreamEvent(_) => "stream_event".to_string(),
476 ClaudeOutput::ToolProgress(_) => "tool_progress".to_string(),
477 ClaudeOutput::CommandLifecycle(_) => "command_lifecycle".to_string(),
478 ClaudeOutput::AuthStatus(_) => "auth_status".to_string(),
479 ClaudeOutput::ToolUseSummary(_) => "tool_use_summary".to_string(),
480 ClaudeOutput::PromptSuggestion(_) => "prompt_suggestion".to_string(),
481 ClaudeOutput::ConversationReset(_) => "conversation_reset".to_string(),
482 ClaudeOutput::Progress(_) => "progress".to_string(),
483 ClaudeOutput::QueueOperation(_) => "queue-operation".to_string(),
484 ClaudeOutput::PrLink(_) => "pr-link".to_string(),
485 ClaudeOutput::FileHistorySnapshot(_) => "file-history-snapshot".to_string(),
486 ClaudeOutput::Summary(_) => "summary".to_string(),
487 ClaudeOutput::Mode(_) => "mode".to_string(),
488 ClaudeOutput::PermissionMode(_) => "permission-mode".to_string(),
489 ClaudeOutput::Attachment(_) => "attachment".to_string(),
490 ClaudeOutput::AiTitle(_) => "ai-title".to_string(),
491 ClaudeOutput::LastPrompt(_) => "last-prompt".to_string(),
492 ClaudeOutput::Started(_) => "started".to_string(),
493 }
494 }
495
496 pub fn is_control_request(&self) -> bool {
498 matches!(self, ClaudeOutput::ControlRequest(_))
499 }
500
501 pub fn is_control_response(&self) -> bool {
503 matches!(self, ClaudeOutput::ControlResponse(_))
504 }
505
506 pub fn is_api_error(&self) -> bool {
508 matches!(self, ClaudeOutput::Error(_))
509 }
510
511 pub fn as_control_request(&self) -> Option<&ControlRequest> {
513 match self {
514 ClaudeOutput::ControlRequest(req) => Some(req),
515 _ => None,
516 }
517 }
518
519 pub fn is_control_cancel_request(&self) -> bool {
521 matches!(self, ClaudeOutput::ControlCancelRequest(_))
522 }
523
524 pub fn as_control_cancel_request(&self) -> Option<&ControlCancelRequest> {
526 match self {
527 ClaudeOutput::ControlCancelRequest(req) => Some(req),
528 _ => None,
529 }
530 }
531
532 pub fn as_anthropic_error(&self) -> Option<&AnthropicError> {
548 match self {
549 ClaudeOutput::Error(err) => Some(err),
550 _ => None,
551 }
552 }
553
554 pub fn is_rate_limit_event(&self) -> bool {
556 matches!(self, ClaudeOutput::RateLimitEvent(_))
557 }
558
559 pub fn as_rate_limit_event(&self) -> Option<&RateLimitEvent> {
561 match self {
562 ClaudeOutput::RateLimitEvent(evt) => Some(evt),
563 _ => None,
564 }
565 }
566
567 pub fn is_error(&self) -> bool {
569 matches!(self, ClaudeOutput::Result(r) if r.is_error)
570 }
571
572 pub fn is_assistant_message(&self) -> bool {
574 matches!(self, ClaudeOutput::Assistant(_))
575 }
576
577 pub fn is_system_message(&self) -> bool {
579 matches!(self, ClaudeOutput::System(_))
580 }
581
582 pub fn is_system_init(&self) -> bool {
593 matches!(self, ClaudeOutput::System(sys) if sys.is_init())
594 }
595
596 pub fn session_id(&self) -> Option<&str> {
612 match self {
613 ClaudeOutput::System(sys) => sys
614 .data
615 .get("session_id")
616 .or_else(|| sys.data.get("sessionId"))
617 .and_then(|v| v.as_str()),
618 ClaudeOutput::Assistant(ass) => Some(&ass.session_id),
619 ClaudeOutput::Result(res) => Some(&res.session_id),
620 ClaudeOutput::TranscriptResult(msg) => msg
621 .data
622 .get("session_id")
623 .or_else(|| msg.data.get("sessionId"))
624 .and_then(|v| v.as_str()),
625 ClaudeOutput::User(_) => None,
626 ClaudeOutput::ControlRequest(_) => None,
627 ClaudeOutput::ControlResponse(_) => None,
628 ClaudeOutput::ControlCancelRequest(msg) => msg.session_id.as_deref(),
629 ClaudeOutput::Error(_) => None,
630 ClaudeOutput::RateLimitEvent(evt) => Some(&evt.session_id),
631 ClaudeOutput::StreamEvent(msg) => Some(&msg.session_id),
632 ClaudeOutput::ToolProgress(msg) => Some(&msg.session_id),
633 ClaudeOutput::CommandLifecycle(msg) => Some(&msg.session_id),
634 ClaudeOutput::AuthStatus(msg) => Some(&msg.session_id),
635 ClaudeOutput::ToolUseSummary(msg) => Some(&msg.session_id),
636 ClaudeOutput::PromptSuggestion(msg) => Some(&msg.session_id),
637 ClaudeOutput::ConversationReset(msg) => Some(&msg.session_id),
638 ClaudeOutput::Progress(msg)
639 | ClaudeOutput::QueueOperation(msg)
640 | ClaudeOutput::PrLink(msg)
641 | ClaudeOutput::FileHistorySnapshot(msg)
642 | ClaudeOutput::Summary(msg)
643 | ClaudeOutput::Mode(msg)
644 | ClaudeOutput::PermissionMode(msg)
645 | ClaudeOutput::Attachment(msg)
646 | ClaudeOutput::AiTitle(msg)
647 | ClaudeOutput::LastPrompt(msg)
648 | ClaudeOutput::Started(msg) => msg
649 .data
650 .get("session_id")
651 .or_else(|| msg.data.get("sessionId"))
652 .and_then(|v| v.as_str()),
653 }
654 }
655
656 pub fn as_tool_use(&self, tool_name: &str) -> Option<&ToolUseBlock> {
675 match self {
676 ClaudeOutput::Assistant(ass) => {
677 ass.message.content.iter().find_map(|block| match block {
678 ContentBlock::ToolUse(tu) if tu.name == tool_name => Some(tu),
679 _ => None,
680 })
681 }
682 _ => None,
683 }
684 }
685
686 pub fn tool_uses(&self) -> impl Iterator<Item = &ToolUseBlock> {
706 let content = match self {
707 ClaudeOutput::Assistant(ass) => Some(&ass.message.content),
708 _ => None,
709 };
710
711 content
712 .into_iter()
713 .flat_map(|c| c.iter())
714 .filter_map(|block| match block {
715 ContentBlock::ToolUse(tu) => Some(tu),
716 _ => None,
717 })
718 }
719
720 pub fn text_content(&self) -> Option<String> {
736 match self {
737 ClaudeOutput::Assistant(ass) => {
738 let texts: Vec<&str> = ass
739 .message
740 .content
741 .iter()
742 .filter_map(|block| match block {
743 ContentBlock::Text(t) => Some(t.text.as_str()),
744 _ => None,
745 })
746 .collect();
747
748 if texts.is_empty() {
749 None
750 } else {
751 Some(texts.join(""))
752 }
753 }
754 _ => None,
755 }
756 }
757
758 pub fn as_assistant(&self) -> Option<&AssistantMessage> {
773 match self {
774 ClaudeOutput::Assistant(ass) => Some(ass),
775 _ => None,
776 }
777 }
778
779 pub fn as_result(&self) -> Option<&ResultMessage> {
795 match self {
796 ClaudeOutput::Result(res) => Some(res),
797 _ => None,
798 }
799 }
800
801 pub fn as_system(&self) -> Option<&SystemMessage> {
803 match self {
804 ClaudeOutput::System(sys) => Some(sys),
805 _ => None,
806 }
807 }
808
809 pub fn parse_json_tolerant(s: &str) -> Result<ClaudeOutput, ParseError> {
814 match Self::parse_json(s) {
816 Ok(output) => Ok(output),
817 Err(first_error) => {
818 if let Some(json_start) = s.find('{') {
820 let trimmed = &s[json_start..];
821 match Self::parse_json(trimmed) {
822 Ok(output) => Ok(output),
823 Err(_) => {
824 Err(first_error)
826 }
827 }
828 } else {
829 Err(first_error)
830 }
831 }
832 }
833 }
834
835 pub fn parse_json(s: &str) -> Result<ClaudeOutput, ParseError> {
837 let value: Value = serde_json::from_str(s).map_err(|e| ParseError {
839 raw_line: s.to_string(),
840 raw_json: None,
841 error_message: format!("Invalid JSON: {}", e),
842 })?;
843
844 serde_json::from_value::<ClaudeOutput>(value.clone()).map_err(|e| ParseError {
846 raw_line: s.to_string(),
847 raw_json: Some(value),
848 error_message: e.to_string(),
849 })
850 }
851}
852
853impl<'de> Deserialize<'de> for ClaudeOutput {
854 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
855 let value = Value::deserialize(deserializer)?;
856 let message_type = value
857 .get("type")
858 .and_then(|v| v.as_str())
859 .ok_or_else(|| serde::de::Error::missing_field("type"))?;
860 let mut payload = value.clone();
861 if let Some(obj) = payload.as_object_mut() {
862 obj.remove("type");
863 }
864
865 fn parse<T, E>(value: Value) -> Result<T, E>
866 where
867 T: serde::de::DeserializeOwned,
868 E: serde::de::Error,
869 {
870 serde_json::from_value(value).map_err(E::custom)
871 }
872
873 match message_type {
874 "system" => parse(payload).map(Self::System),
875 "user" => parse(payload).map(Self::User),
876 "assistant" => parse(payload).map(Self::Assistant),
877 "result" if value.get("subtype").is_some() => parse(payload).map(Self::Result),
878 "result" => parse(payload).map(Self::TranscriptResult),
879 "control_request" => parse(payload).map(Self::ControlRequest),
880 "control_response" => parse(payload).map(Self::ControlResponse),
881 "control_cancel_request" => parse(payload).map(Self::ControlCancelRequest),
882 "error" => parse(payload).map(Self::Error),
883 "rate_limit_event" => parse(payload).map(Self::RateLimitEvent),
884 "stream_event" => parse(payload).map(Self::StreamEvent),
885 "tool_progress" => parse(payload).map(Self::ToolProgress),
886 "command_lifecycle" => parse(payload).map(Self::CommandLifecycle),
887 "auth_status" => parse(payload).map(Self::AuthStatus),
888 "tool_use_summary" => parse(payload).map(Self::ToolUseSummary),
889 "prompt_suggestion" => parse(payload).map(Self::PromptSuggestion),
890 "conversation_reset" => parse(payload).map(Self::ConversationReset),
891 "progress" => parse(payload).map(Self::Progress),
892 "queue-operation" => parse(payload).map(Self::QueueOperation),
893 "pr-link" => parse(payload).map(Self::PrLink),
894 "file-history-snapshot" => parse(payload).map(Self::FileHistorySnapshot),
895 "summary" => parse(payload).map(Self::Summary),
896 "mode" => parse(payload).map(Self::Mode),
897 "permission-mode" => parse(payload).map(Self::PermissionMode),
898 "attachment" => parse(payload).map(Self::Attachment),
899 "ai-title" => parse(payload).map(Self::AiTitle),
900 "last-prompt" => parse(payload).map(Self::LastPrompt),
901 "started" => parse(payload).map(Self::Started),
902 other => Err(serde::de::Error::unknown_variant(
903 other,
904 &[
905 "system",
906 "user",
907 "assistant",
908 "result",
909 "control_request",
910 "control_response",
911 "control_cancel_request",
912 "error",
913 "rate_limit_event",
914 "stream_event",
915 "tool_progress",
916 "command_lifecycle",
917 "auth_status",
918 "tool_use_summary",
919 "prompt_suggestion",
920 "conversation_reset",
921 "progress",
922 "queue-operation",
923 "pr-link",
924 "file-history-snapshot",
925 "summary",
926 "mode",
927 "permission-mode",
928 "attachment",
929 "ai-title",
930 "last-prompt",
931 "started",
932 ],
933 )),
934 }
935 }
936}
937
938#[cfg(test)]
939mod tests {
940 use super::*;
941
942 #[test]
943 fn test_deserialize_control_cancel_request() {
944 let json = r#"{"request_id":"00aea85d-70f9-4cc4-becc-b0f05f5037f3","type":"control_cancel_request"}"#;
945 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
946 assert!(output.is_control_cancel_request());
947 assert_eq!(output.message_type(), "control_cancel_request");
948 assert_eq!(output.session_id(), None);
949 let cancel = output.as_control_cancel_request().unwrap();
950 assert_eq!(cancel.request_id, "00aea85d-70f9-4cc4-becc-b0f05f5037f3");
951
952 let round_trip: Value = serde_json::to_value(&output).unwrap();
953 assert_eq!(round_trip, serde_json::from_str::<Value>(json).unwrap());
954
955 let bridged = r#"{"type":"control_cancel_request","request_id":"r1","session_id":"s1"}"#;
956 let output: ClaudeOutput = serde_json::from_str(bridged).unwrap();
957 assert_eq!(output.session_id(), Some("s1"));
958 }
959
960 #[test]
961 fn test_deserialize_assistant_message() {
962 let json = r#"{
963 "type": "assistant",
964 "message": {
965 "id": "msg_123",
966 "role": "assistant",
967 "model": "claude-3-sonnet",
968 "content": [{"type": "text", "text": "Hello! How can I help you?"}]
969 },
970 "session_id": "123"
971 }"#;
972
973 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
974 assert!(output.is_assistant_message());
975 }
976
977 #[test]
978 fn test_deserialize_new_top_level_message_types() {
979 let cases = [
980 (
981 r#"{"type":"stream_event","event":{"type":"content_block_delta"},"parent_tool_use_id":null,"uuid":"u1","session_id":"s1","ttft_ms":12}"#,
982 "stream_event",
983 ),
984 (
985 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"}"#,
986 "tool_progress",
987 ),
988 (
989 r#"{"type":"auth_status","isAuthenticating":true,"output":["login"],"uuid":"u3","session_id":"s3"}"#,
990 "auth_status",
991 ),
992 (
993 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"}"#,
994 "tool_use_summary",
995 ),
996 (
997 r#"{"type":"prompt_suggestion","suggestion":"Run tests","uuid":"u5","session_id":"s5"}"#,
998 "prompt_suggestion",
999 ),
1000 (
1001 r#"{"type":"conversation_reset","new_conversation_id":"new-session","uuid":"u6","session_id":"s6"}"#,
1002 "conversation_reset",
1003 ),
1004 (
1005 r#"{"type":"command_lifecycle","command_uuid":"cmd-1","state":"queued","uuid":"u7","session_id":"s7"}"#,
1006 "command_lifecycle",
1007 ),
1008 ];
1009
1010 for (json, message_type) in cases {
1011 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1012 assert_eq!(output.message_type(), message_type);
1013 assert!(output.session_id().is_some());
1014 }
1015 }
1016
1017 #[test]
1018 fn test_command_lifecycle_states_roundtrip() {
1019 for state in ["queued", "started", "completed", "cancelled", "discarded"] {
1020 let json = format!(
1021 r#"{{"type":"command_lifecycle","command_uuid":"cmd-1","state":"{}","uuid":"u1","session_id":"s1"}}"#,
1022 state
1023 );
1024 let output: ClaudeOutput = serde_json::from_str(&json).unwrap();
1025 let ClaudeOutput::CommandLifecycle(msg) = &output else {
1026 panic!("expected CommandLifecycle");
1027 };
1028 assert_eq!(msg.state.as_str(), state);
1029 assert!(!matches!(msg.state, CommandLifecycleState::Unknown(_)));
1030 assert_eq!(serde_json::to_string(&output).unwrap(), json);
1031 }
1032
1033 let json = r#"{"type":"command_lifecycle","command_uuid":"cmd-2","state":"parked","uuid":"u2","session_id":"s2"}"#;
1035 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1036 let ClaudeOutput::CommandLifecycle(msg) = &output else {
1037 panic!("expected CommandLifecycle");
1038 };
1039 assert_eq!(
1040 msg.state,
1041 CommandLifecycleState::Unknown("parked".to_string())
1042 );
1043 assert_eq!(serde_json::to_string(&output).unwrap(), json);
1044 }
1045
1046 #[test]
1047 fn test_tool_progress_heartbeat_and_subagent_retry() {
1048 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"}}"#;
1049 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1050 let ClaudeOutput::ToolProgress(msg) = &output else {
1051 panic!("expected ToolProgress");
1052 };
1053 assert_eq!(msg.heartbeat, Some(true));
1054 assert_eq!(msg.subagent_type.as_deref(), Some("Explore"));
1055 let retry = msg.subagent_retry.as_ref().unwrap();
1056 assert_eq!(retry.attempt, 2);
1057 assert_eq!(retry.error_status, Some(529));
1058 assert_eq!(retry.error_category, "overloaded");
1059
1060 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"}}"#;
1062 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1063 let ClaudeOutput::ToolProgress(msg) = &output else {
1064 panic!("expected ToolProgress");
1065 };
1066 assert_eq!(msg.subagent_retry.as_ref().unwrap().error_status, None);
1067 }
1068
1069 #[test]
1070 fn test_deserialize_transcript_only_message_types() {
1071 let cases = [
1072 (
1073 r#"{"type":"progress","sessionId":"s1","content":"delta"}"#,
1074 "progress",
1075 Some("s1"),
1076 ),
1077 (
1078 r#"{"type":"queue-operation","sessionId":"s2","operation":"push"}"#,
1079 "queue-operation",
1080 Some("s2"),
1081 ),
1082 (
1083 r#"{"type":"pr-link","sessionId":"s3","url":"https://example.invalid/pr"}"#,
1084 "pr-link",
1085 Some("s3"),
1086 ),
1087 (
1088 r#"{"type":"file-history-snapshot","sessionId":"s4","files":[]}"#,
1089 "file-history-snapshot",
1090 Some("s4"),
1091 ),
1092 (r#"{"type":"summary","summary":"Done"}"#, "summary", None),
1093 (
1094 r#"{"type":"mode","mode":"normal","sessionId":"s5"}"#,
1095 "mode",
1096 Some("s5"),
1097 ),
1098 (
1099 r#"{"type":"permission-mode","permissionMode":"default","sessionId":"s6"}"#,
1100 "permission-mode",
1101 Some("s6"),
1102 ),
1103 (
1104 r#"{"type":"attachment","attachment":{"type":"task_reminder"},"sessionId":"s7"}"#,
1105 "attachment",
1106 Some("s7"),
1107 ),
1108 (
1109 r#"{"type":"ai-title","aiTitle":"Title","sessionId":"s8"}"#,
1110 "ai-title",
1111 Some("s8"),
1112 ),
1113 (
1114 r#"{"type":"last-prompt","lastPrompt":"prompt","sessionId":"s9"}"#,
1115 "last-prompt",
1116 Some("s9"),
1117 ),
1118 (
1119 r#"{"type":"started","sessionId":"s10"}"#,
1120 "started",
1121 Some("s10"),
1122 ),
1123 (
1124 r#"{"type":"result","key":"k","agentId":"a","result":{"ok":true}}"#,
1125 "result",
1126 None,
1127 ),
1128 ];
1129
1130 for (json, message_type, session_id) in cases {
1131 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1132 assert_eq!(output.message_type(), message_type);
1133 assert_eq!(output.session_id(), session_id);
1134 }
1135 }
1136
1137 #[test]
1138 fn test_transcript_assistant_accepts_camel_case_session_id() {
1139 let json = r#"{
1140 "type": "assistant",
1141 "sessionId": "camel-session",
1142 "message": {
1143 "id": "msg_123",
1144 "role": "assistant",
1145 "model": "claude-3-sonnet",
1146 "content": [{"type": "text", "text": "Hello"}]
1147 }
1148 }"#;
1149
1150 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1151 assert_eq!(output.session_id(), Some("camel-session"));
1152 }
1153
1154 #[test]
1155 fn test_is_system_init() {
1156 let init_json = r#"{
1157 "type": "system",
1158 "subtype": "init",
1159 "session_id": "test-session"
1160 }"#;
1161 let output: ClaudeOutput = serde_json::from_str(init_json).unwrap();
1162 assert!(output.is_system_init());
1163
1164 let status_json = r#"{
1165 "type": "system",
1166 "subtype": "status",
1167 "session_id": "test-session"
1168 }"#;
1169 let output: ClaudeOutput = serde_json::from_str(status_json).unwrap();
1170 assert!(!output.is_system_init());
1171 }
1172
1173 #[test]
1174 fn test_session_id() {
1175 let result_json = r#"{
1177 "type": "result",
1178 "subtype": "success",
1179 "is_error": false,
1180 "duration_ms": 100,
1181 "duration_api_ms": 200,
1182 "num_turns": 1,
1183 "session_id": "result-session",
1184 "total_cost_usd": 0.01
1185 }"#;
1186 let output: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1187 assert_eq!(output.session_id(), Some("result-session"));
1188
1189 let assistant_json = r#"{
1191 "type": "assistant",
1192 "message": {
1193 "id": "msg_1",
1194 "role": "assistant",
1195 "model": "claude-3",
1196 "content": []
1197 },
1198 "session_id": "assistant-session"
1199 }"#;
1200 let output: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
1201 assert_eq!(output.session_id(), Some("assistant-session"));
1202
1203 let system_json = r#"{
1205 "type": "system",
1206 "subtype": "init",
1207 "session_id": "system-session"
1208 }"#;
1209 let output: ClaudeOutput = serde_json::from_str(system_json).unwrap();
1210 assert_eq!(output.session_id(), Some("system-session"));
1211 }
1212
1213 #[test]
1214 fn test_as_tool_use() {
1215 let json = r#"{
1216 "type": "assistant",
1217 "message": {
1218 "id": "msg_1",
1219 "role": "assistant",
1220 "model": "claude-3",
1221 "content": [
1222 {"type": "text", "text": "Let me run that command."},
1223 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls -la"}},
1224 {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/test"}}
1225 ]
1226 },
1227 "session_id": "abc"
1228 }"#;
1229 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1230
1231 let bash = output.as_tool_use("Bash");
1233 assert!(bash.is_some());
1234 assert_eq!(bash.unwrap().id, "tu_1");
1235
1236 let read = output.as_tool_use("Read");
1238 assert!(read.is_some());
1239 assert_eq!(read.unwrap().id, "tu_2");
1240
1241 assert!(output.as_tool_use("Write").is_none());
1243
1244 let result_json = r#"{
1246 "type": "result",
1247 "subtype": "success",
1248 "is_error": false,
1249 "duration_ms": 100,
1250 "duration_api_ms": 200,
1251 "num_turns": 1,
1252 "session_id": "abc",
1253 "total_cost_usd": 0.01
1254 }"#;
1255 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1256 assert!(result.as_tool_use("Bash").is_none());
1257 }
1258
1259 #[test]
1260 fn test_tool_uses() {
1261 let json = r#"{
1262 "type": "assistant",
1263 "message": {
1264 "id": "msg_1",
1265 "role": "assistant",
1266 "model": "claude-3",
1267 "content": [
1268 {"type": "text", "text": "Running commands..."},
1269 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls"}},
1270 {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/a"}},
1271 {"type": "tool_use", "id": "tu_3", "name": "Write", "input": {"file_path": "/tmp/b", "content": "x"}}
1272 ]
1273 },
1274 "session_id": "abc"
1275 }"#;
1276 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1277
1278 let tools: Vec<_> = output.tool_uses().collect();
1279 assert_eq!(tools.len(), 3);
1280 assert_eq!(tools[0].name, "Bash");
1281 assert_eq!(tools[1].name, "Read");
1282 assert_eq!(tools[2].name, "Write");
1283 }
1284
1285 #[test]
1286 fn test_text_content() {
1287 let json = r#"{
1289 "type": "assistant",
1290 "message": {
1291 "id": "msg_1",
1292 "role": "assistant",
1293 "model": "claude-3",
1294 "content": [{"type": "text", "text": "Hello, world!"}]
1295 },
1296 "session_id": "abc"
1297 }"#;
1298 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1299 assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
1300
1301 let json = r#"{
1303 "type": "assistant",
1304 "message": {
1305 "id": "msg_1",
1306 "role": "assistant",
1307 "model": "claude-3",
1308 "content": [
1309 {"type": "text", "text": "Hello, "},
1310 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}},
1311 {"type": "text", "text": "world!"}
1312 ]
1313 },
1314 "session_id": "abc"
1315 }"#;
1316 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1317 assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
1318
1319 let json = r#"{
1321 "type": "assistant",
1322 "message": {
1323 "id": "msg_1",
1324 "role": "assistant",
1325 "model": "claude-3",
1326 "content": [{"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}}]
1327 },
1328 "session_id": "abc"
1329 }"#;
1330 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1331 assert_eq!(output.text_content(), None);
1332
1333 let json = r#"{
1335 "type": "result",
1336 "subtype": "success",
1337 "is_error": false,
1338 "duration_ms": 100,
1339 "duration_api_ms": 200,
1340 "num_turns": 1,
1341 "session_id": "abc",
1342 "total_cost_usd": 0.01
1343 }"#;
1344 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1345 assert_eq!(output.text_content(), None);
1346 }
1347
1348 #[test]
1349 fn test_as_assistant() {
1350 let json = r#"{
1351 "type": "assistant",
1352 "message": {
1353 "id": "msg_1",
1354 "role": "assistant",
1355 "model": "claude-sonnet-4",
1356 "content": []
1357 },
1358 "session_id": "abc"
1359 }"#;
1360 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1361
1362 let assistant = output.as_assistant();
1363 assert!(assistant.is_some());
1364 assert_eq!(assistant.unwrap().message.model, "claude-sonnet-4");
1365
1366 let result_json = r#"{
1368 "type": "result",
1369 "subtype": "success",
1370 "is_error": false,
1371 "duration_ms": 100,
1372 "duration_api_ms": 200,
1373 "num_turns": 1,
1374 "session_id": "abc",
1375 "total_cost_usd": 0.01
1376 }"#;
1377 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1378 assert!(result.as_assistant().is_none());
1379 }
1380
1381 #[test]
1382 fn test_as_result() {
1383 let json = r#"{
1384 "type": "result",
1385 "subtype": "success",
1386 "is_error": false,
1387 "duration_ms": 100,
1388 "duration_api_ms": 200,
1389 "num_turns": 5,
1390 "session_id": "abc",
1391 "total_cost_usd": 0.05
1392 }"#;
1393 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1394
1395 let result = output.as_result();
1396 assert!(result.is_some());
1397 assert_eq!(result.unwrap().num_turns, 5);
1398 assert_eq!(result.unwrap().total_cost_usd, 0.05);
1399
1400 let assistant_json = r#"{
1402 "type": "assistant",
1403 "message": {
1404 "id": "msg_1",
1405 "role": "assistant",
1406 "model": "claude-3",
1407 "content": []
1408 },
1409 "session_id": "abc"
1410 }"#;
1411 let assistant: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
1412 assert!(assistant.as_result().is_none());
1413 }
1414
1415 #[test]
1416 fn test_as_system() {
1417 let json = r#"{
1418 "type": "system",
1419 "subtype": "init",
1420 "session_id": "abc",
1421 "model": "claude-3"
1422 }"#;
1423 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1424
1425 let system = output.as_system();
1426 assert!(system.is_some());
1427 assert!(system.unwrap().is_init());
1428
1429 let result_json = r#"{
1431 "type": "result",
1432 "subtype": "success",
1433 "is_error": false,
1434 "duration_ms": 100,
1435 "duration_api_ms": 200,
1436 "num_turns": 1,
1437 "session_id": "abc",
1438 "total_cost_usd": 0.01
1439 }"#;
1440 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1441 assert!(result.as_system().is_none());
1442 }
1443}