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 pub uuid: String,
238 pub session_id: String,
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Hash)]
243pub enum CommandLifecycleState {
244 Queued,
246 Started,
248 Completed,
250 Cancelled,
253 Discarded,
255 Unknown(String),
257}
258
259impl CommandLifecycleState {
260 pub fn as_str(&self) -> &str {
261 match self {
262 Self::Queued => "queued",
263 Self::Started => "started",
264 Self::Completed => "completed",
265 Self::Cancelled => "cancelled",
266 Self::Discarded => "discarded",
267 Self::Unknown(s) => s.as_str(),
268 }
269 }
270}
271
272impl std::fmt::Display for CommandLifecycleState {
273 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274 f.write_str(self.as_str())
275 }
276}
277
278impl From<&str> for CommandLifecycleState {
279 fn from(s: &str) -> Self {
280 match s {
281 "queued" => Self::Queued,
282 "started" => Self::Started,
283 "completed" => Self::Completed,
284 "cancelled" => Self::Cancelled,
285 "discarded" => Self::Discarded,
286 other => Self::Unknown(other.to_string()),
287 }
288 }
289}
290
291impl Serialize for CommandLifecycleState {
292 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
293 serializer.serialize_str(self.as_str())
294 }
295}
296
297impl<'de> Deserialize<'de> for CommandLifecycleState {
298 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
299 let s = String::deserialize(deserializer)?;
300 Ok(Self::from(s.as_str()))
301 }
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct AuthStatusMessage {
306 #[serde(rename = "isAuthenticating")]
307 pub is_authenticating: bool,
308 pub output: Vec<String>,
309 #[serde(skip_serializing_if = "Option::is_none")]
310 pub error: Option<String>,
311 pub uuid: String,
312 pub session_id: String,
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct ToolUseSummaryMessage {
317 pub summary: String,
318 pub preceding_tool_use_ids: Vec<String>,
319 pub uuid: String,
320 pub session_id: String,
321 #[serde(skip_serializing_if = "Option::is_none")]
322 pub timestamp: Option<String>,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct PromptSuggestionMessage {
327 pub suggestion: String,
328 pub uuid: String,
329 pub session_id: String,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct ConversationResetMessage {
351 pub new_conversation_id: String,
355 pub uuid: String,
356 pub session_id: String,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub trigger: Option<ConversationResetTrigger>,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub user_message_uuid: Option<String>,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub timestamp: Option<String>,
378}
379
380#[derive(Debug, Clone, PartialEq, Eq, Hash)]
383pub enum ConversationResetTrigger {
384 Clear,
386 PlanModeExit,
388 FreshSession,
390 Onboarding,
392 Unknown(String),
394}
395
396impl ConversationResetTrigger {
397 pub fn as_str(&self) -> &str {
398 match self {
399 Self::Clear => "clear",
400 Self::PlanModeExit => "plan_mode_exit",
401 Self::FreshSession => "fresh_session",
402 Self::Onboarding => "onboarding",
403 Self::Unknown(s) => s.as_str(),
404 }
405 }
406}
407
408impl std::fmt::Display for ConversationResetTrigger {
409 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
410 f.write_str(self.as_str())
411 }
412}
413
414impl From<&str> for ConversationResetTrigger {
415 fn from(s: &str) -> Self {
416 match s {
417 "clear" => Self::Clear,
418 "plan_mode_exit" => Self::PlanModeExit,
419 "fresh_session" => Self::FreshSession,
420 "onboarding" => Self::Onboarding,
421 other => Self::Unknown(other.to_string()),
422 }
423 }
424}
425
426impl Serialize for ConversationResetTrigger {
427 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
428 serializer.serialize_str(self.as_str())
429 }
430}
431
432impl<'de> Deserialize<'de> for ConversationResetTrigger {
433 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
434 let s = String::deserialize(deserializer)?;
435 Ok(Self::from(s.as_str()))
436 }
437}
438
439#[derive(Debug, Clone, Serialize, Deserialize)]
446pub struct TranscriptMessage {
447 #[serde(flatten)]
448 pub data: Map<String, Value>,
449}
450
451impl ClaudeOutput {
452 pub fn message_type(&self) -> String {
454 match self {
455 ClaudeOutput::System(_) => "system".to_string(),
456 ClaudeOutput::User(_) => "user".to_string(),
457 ClaudeOutput::Assistant(_) => "assistant".to_string(),
458 ClaudeOutput::Result(_) => "result".to_string(),
459 ClaudeOutput::TranscriptResult(_) => "result".to_string(),
460 ClaudeOutput::ControlRequest(_) => "control_request".to_string(),
461 ClaudeOutput::ControlResponse(_) => "control_response".to_string(),
462 ClaudeOutput::Error(_) => "error".to_string(),
463 ClaudeOutput::RateLimitEvent(_) => "rate_limit_event".to_string(),
464 ClaudeOutput::StreamEvent(_) => "stream_event".to_string(),
465 ClaudeOutput::ToolProgress(_) => "tool_progress".to_string(),
466 ClaudeOutput::CommandLifecycle(_) => "command_lifecycle".to_string(),
467 ClaudeOutput::AuthStatus(_) => "auth_status".to_string(),
468 ClaudeOutput::ToolUseSummary(_) => "tool_use_summary".to_string(),
469 ClaudeOutput::PromptSuggestion(_) => "prompt_suggestion".to_string(),
470 ClaudeOutput::ConversationReset(_) => "conversation_reset".to_string(),
471 ClaudeOutput::Progress(_) => "progress".to_string(),
472 ClaudeOutput::QueueOperation(_) => "queue-operation".to_string(),
473 ClaudeOutput::PrLink(_) => "pr-link".to_string(),
474 ClaudeOutput::FileHistorySnapshot(_) => "file-history-snapshot".to_string(),
475 ClaudeOutput::Summary(_) => "summary".to_string(),
476 ClaudeOutput::Mode(_) => "mode".to_string(),
477 ClaudeOutput::PermissionMode(_) => "permission-mode".to_string(),
478 ClaudeOutput::Attachment(_) => "attachment".to_string(),
479 ClaudeOutput::AiTitle(_) => "ai-title".to_string(),
480 ClaudeOutput::LastPrompt(_) => "last-prompt".to_string(),
481 ClaudeOutput::Started(_) => "started".to_string(),
482 }
483 }
484
485 pub fn is_control_request(&self) -> bool {
487 matches!(self, ClaudeOutput::ControlRequest(_))
488 }
489
490 pub fn is_control_response(&self) -> bool {
492 matches!(self, ClaudeOutput::ControlResponse(_))
493 }
494
495 pub fn is_api_error(&self) -> bool {
497 matches!(self, ClaudeOutput::Error(_))
498 }
499
500 pub fn as_control_request(&self) -> Option<&ControlRequest> {
502 match self {
503 ClaudeOutput::ControlRequest(req) => Some(req),
504 _ => None,
505 }
506 }
507
508 pub fn as_anthropic_error(&self) -> Option<&AnthropicError> {
524 match self {
525 ClaudeOutput::Error(err) => Some(err),
526 _ => None,
527 }
528 }
529
530 pub fn is_rate_limit_event(&self) -> bool {
532 matches!(self, ClaudeOutput::RateLimitEvent(_))
533 }
534
535 pub fn as_rate_limit_event(&self) -> Option<&RateLimitEvent> {
537 match self {
538 ClaudeOutput::RateLimitEvent(evt) => Some(evt),
539 _ => None,
540 }
541 }
542
543 pub fn is_error(&self) -> bool {
545 matches!(self, ClaudeOutput::Result(r) if r.is_error)
546 }
547
548 pub fn is_assistant_message(&self) -> bool {
550 matches!(self, ClaudeOutput::Assistant(_))
551 }
552
553 pub fn is_system_message(&self) -> bool {
555 matches!(self, ClaudeOutput::System(_))
556 }
557
558 pub fn is_system_init(&self) -> bool {
569 matches!(self, ClaudeOutput::System(sys) if sys.is_init())
570 }
571
572 pub fn session_id(&self) -> Option<&str> {
588 match self {
589 ClaudeOutput::System(sys) => sys
590 .data
591 .get("session_id")
592 .or_else(|| sys.data.get("sessionId"))
593 .and_then(|v| v.as_str()),
594 ClaudeOutput::Assistant(ass) => Some(&ass.session_id),
595 ClaudeOutput::Result(res) => Some(&res.session_id),
596 ClaudeOutput::TranscriptResult(msg) => msg
597 .data
598 .get("session_id")
599 .or_else(|| msg.data.get("sessionId"))
600 .and_then(|v| v.as_str()),
601 ClaudeOutput::User(_) => None,
602 ClaudeOutput::ControlRequest(_) => None,
603 ClaudeOutput::ControlResponse(_) => None,
604 ClaudeOutput::Error(_) => None,
605 ClaudeOutput::RateLimitEvent(evt) => Some(&evt.session_id),
606 ClaudeOutput::StreamEvent(msg) => Some(&msg.session_id),
607 ClaudeOutput::ToolProgress(msg) => Some(&msg.session_id),
608 ClaudeOutput::CommandLifecycle(msg) => Some(&msg.session_id),
609 ClaudeOutput::AuthStatus(msg) => Some(&msg.session_id),
610 ClaudeOutput::ToolUseSummary(msg) => Some(&msg.session_id),
611 ClaudeOutput::PromptSuggestion(msg) => Some(&msg.session_id),
612 ClaudeOutput::ConversationReset(msg) => Some(&msg.session_id),
613 ClaudeOutput::Progress(msg)
614 | ClaudeOutput::QueueOperation(msg)
615 | ClaudeOutput::PrLink(msg)
616 | ClaudeOutput::FileHistorySnapshot(msg)
617 | ClaudeOutput::Summary(msg)
618 | ClaudeOutput::Mode(msg)
619 | ClaudeOutput::PermissionMode(msg)
620 | ClaudeOutput::Attachment(msg)
621 | ClaudeOutput::AiTitle(msg)
622 | ClaudeOutput::LastPrompt(msg)
623 | ClaudeOutput::Started(msg) => msg
624 .data
625 .get("session_id")
626 .or_else(|| msg.data.get("sessionId"))
627 .and_then(|v| v.as_str()),
628 }
629 }
630
631 pub fn as_tool_use(&self, tool_name: &str) -> Option<&ToolUseBlock> {
650 match self {
651 ClaudeOutput::Assistant(ass) => {
652 ass.message.content.iter().find_map(|block| match block {
653 ContentBlock::ToolUse(tu) if tu.name == tool_name => Some(tu),
654 _ => None,
655 })
656 }
657 _ => None,
658 }
659 }
660
661 pub fn tool_uses(&self) -> impl Iterator<Item = &ToolUseBlock> {
681 let content = match self {
682 ClaudeOutput::Assistant(ass) => Some(&ass.message.content),
683 _ => None,
684 };
685
686 content
687 .into_iter()
688 .flat_map(|c| c.iter())
689 .filter_map(|block| match block {
690 ContentBlock::ToolUse(tu) => Some(tu),
691 _ => None,
692 })
693 }
694
695 pub fn text_content(&self) -> Option<String> {
711 match self {
712 ClaudeOutput::Assistant(ass) => {
713 let texts: Vec<&str> = ass
714 .message
715 .content
716 .iter()
717 .filter_map(|block| match block {
718 ContentBlock::Text(t) => Some(t.text.as_str()),
719 _ => None,
720 })
721 .collect();
722
723 if texts.is_empty() {
724 None
725 } else {
726 Some(texts.join(""))
727 }
728 }
729 _ => None,
730 }
731 }
732
733 pub fn as_assistant(&self) -> Option<&AssistantMessage> {
748 match self {
749 ClaudeOutput::Assistant(ass) => Some(ass),
750 _ => None,
751 }
752 }
753
754 pub fn as_result(&self) -> Option<&ResultMessage> {
770 match self {
771 ClaudeOutput::Result(res) => Some(res),
772 _ => None,
773 }
774 }
775
776 pub fn as_system(&self) -> Option<&SystemMessage> {
778 match self {
779 ClaudeOutput::System(sys) => Some(sys),
780 _ => None,
781 }
782 }
783
784 pub fn parse_json_tolerant(s: &str) -> Result<ClaudeOutput, ParseError> {
789 match Self::parse_json(s) {
791 Ok(output) => Ok(output),
792 Err(first_error) => {
793 if let Some(json_start) = s.find('{') {
795 let trimmed = &s[json_start..];
796 match Self::parse_json(trimmed) {
797 Ok(output) => Ok(output),
798 Err(_) => {
799 Err(first_error)
801 }
802 }
803 } else {
804 Err(first_error)
805 }
806 }
807 }
808 }
809
810 pub fn parse_json(s: &str) -> Result<ClaudeOutput, ParseError> {
812 let value: Value = serde_json::from_str(s).map_err(|e| ParseError {
814 raw_line: s.to_string(),
815 raw_json: None,
816 error_message: format!("Invalid JSON: {}", e),
817 })?;
818
819 serde_json::from_value::<ClaudeOutput>(value.clone()).map_err(|e| ParseError {
821 raw_line: s.to_string(),
822 raw_json: Some(value),
823 error_message: e.to_string(),
824 })
825 }
826}
827
828impl<'de> Deserialize<'de> for ClaudeOutput {
829 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
830 let value = Value::deserialize(deserializer)?;
831 let message_type = value
832 .get("type")
833 .and_then(|v| v.as_str())
834 .ok_or_else(|| serde::de::Error::missing_field("type"))?;
835 let mut payload = value.clone();
836 if let Some(obj) = payload.as_object_mut() {
837 obj.remove("type");
838 }
839
840 fn parse<T, E>(value: Value) -> Result<T, E>
841 where
842 T: serde::de::DeserializeOwned,
843 E: serde::de::Error,
844 {
845 serde_json::from_value(value).map_err(E::custom)
846 }
847
848 match message_type {
849 "system" => parse(payload).map(Self::System),
850 "user" => parse(payload).map(Self::User),
851 "assistant" => parse(payload).map(Self::Assistant),
852 "result" if value.get("subtype").is_some() => parse(payload).map(Self::Result),
853 "result" => parse(payload).map(Self::TranscriptResult),
854 "control_request" => parse(payload).map(Self::ControlRequest),
855 "control_response" => parse(payload).map(Self::ControlResponse),
856 "error" => parse(payload).map(Self::Error),
857 "rate_limit_event" => parse(payload).map(Self::RateLimitEvent),
858 "stream_event" => parse(payload).map(Self::StreamEvent),
859 "tool_progress" => parse(payload).map(Self::ToolProgress),
860 "command_lifecycle" => parse(payload).map(Self::CommandLifecycle),
861 "auth_status" => parse(payload).map(Self::AuthStatus),
862 "tool_use_summary" => parse(payload).map(Self::ToolUseSummary),
863 "prompt_suggestion" => parse(payload).map(Self::PromptSuggestion),
864 "conversation_reset" => parse(payload).map(Self::ConversationReset),
865 "progress" => parse(payload).map(Self::Progress),
866 "queue-operation" => parse(payload).map(Self::QueueOperation),
867 "pr-link" => parse(payload).map(Self::PrLink),
868 "file-history-snapshot" => parse(payload).map(Self::FileHistorySnapshot),
869 "summary" => parse(payload).map(Self::Summary),
870 "mode" => parse(payload).map(Self::Mode),
871 "permission-mode" => parse(payload).map(Self::PermissionMode),
872 "attachment" => parse(payload).map(Self::Attachment),
873 "ai-title" => parse(payload).map(Self::AiTitle),
874 "last-prompt" => parse(payload).map(Self::LastPrompt),
875 "started" => parse(payload).map(Self::Started),
876 other => Err(serde::de::Error::unknown_variant(
877 other,
878 &[
879 "system",
880 "user",
881 "assistant",
882 "result",
883 "control_request",
884 "control_response",
885 "error",
886 "rate_limit_event",
887 "stream_event",
888 "tool_progress",
889 "command_lifecycle",
890 "auth_status",
891 "tool_use_summary",
892 "prompt_suggestion",
893 "conversation_reset",
894 "progress",
895 "queue-operation",
896 "pr-link",
897 "file-history-snapshot",
898 "summary",
899 "mode",
900 "permission-mode",
901 "attachment",
902 "ai-title",
903 "last-prompt",
904 "started",
905 ],
906 )),
907 }
908 }
909}
910
911#[cfg(test)]
912mod tests {
913 use super::*;
914
915 #[test]
916 fn test_deserialize_assistant_message() {
917 let json = r#"{
918 "type": "assistant",
919 "message": {
920 "id": "msg_123",
921 "role": "assistant",
922 "model": "claude-3-sonnet",
923 "content": [{"type": "text", "text": "Hello! How can I help you?"}]
924 },
925 "session_id": "123"
926 }"#;
927
928 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
929 assert!(output.is_assistant_message());
930 }
931
932 #[test]
933 fn test_deserialize_new_top_level_message_types() {
934 let cases = [
935 (
936 r#"{"type":"stream_event","event":{"type":"content_block_delta"},"parent_tool_use_id":null,"uuid":"u1","session_id":"s1","ttft_ms":12}"#,
937 "stream_event",
938 ),
939 (
940 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"}"#,
941 "tool_progress",
942 ),
943 (
944 r#"{"type":"auth_status","isAuthenticating":true,"output":["login"],"uuid":"u3","session_id":"s3"}"#,
945 "auth_status",
946 ),
947 (
948 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"}"#,
949 "tool_use_summary",
950 ),
951 (
952 r#"{"type":"prompt_suggestion","suggestion":"Run tests","uuid":"u5","session_id":"s5"}"#,
953 "prompt_suggestion",
954 ),
955 (
956 r#"{"type":"conversation_reset","new_conversation_id":"new-session","uuid":"u6","session_id":"s6"}"#,
957 "conversation_reset",
958 ),
959 (
960 r#"{"type":"command_lifecycle","command_uuid":"cmd-1","state":"queued","uuid":"u7","session_id":"s7"}"#,
961 "command_lifecycle",
962 ),
963 ];
964
965 for (json, message_type) in cases {
966 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
967 assert_eq!(output.message_type(), message_type);
968 assert!(output.session_id().is_some());
969 }
970 }
971
972 #[test]
973 fn test_command_lifecycle_states_roundtrip() {
974 for state in ["queued", "started", "completed", "cancelled", "discarded"] {
975 let json = format!(
976 r#"{{"type":"command_lifecycle","command_uuid":"cmd-1","state":"{}","uuid":"u1","session_id":"s1"}}"#,
977 state
978 );
979 let output: ClaudeOutput = serde_json::from_str(&json).unwrap();
980 let ClaudeOutput::CommandLifecycle(msg) = &output else {
981 panic!("expected CommandLifecycle");
982 };
983 assert_eq!(msg.state.as_str(), state);
984 assert!(!matches!(msg.state, CommandLifecycleState::Unknown(_)));
985 assert_eq!(serde_json::to_string(&output).unwrap(), json);
986 }
987
988 let json = r#"{"type":"command_lifecycle","command_uuid":"cmd-2","state":"parked","uuid":"u2","session_id":"s2"}"#;
990 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
991 let ClaudeOutput::CommandLifecycle(msg) = &output else {
992 panic!("expected CommandLifecycle");
993 };
994 assert_eq!(
995 msg.state,
996 CommandLifecycleState::Unknown("parked".to_string())
997 );
998 assert_eq!(serde_json::to_string(&output).unwrap(), json);
999 }
1000
1001 #[test]
1002 fn test_tool_progress_heartbeat_and_subagent_retry() {
1003 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"}}"#;
1004 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1005 let ClaudeOutput::ToolProgress(msg) = &output else {
1006 panic!("expected ToolProgress");
1007 };
1008 assert_eq!(msg.heartbeat, Some(true));
1009 assert_eq!(msg.subagent_type.as_deref(), Some("Explore"));
1010 let retry = msg.subagent_retry.as_ref().unwrap();
1011 assert_eq!(retry.attempt, 2);
1012 assert_eq!(retry.error_status, Some(529));
1013 assert_eq!(retry.error_category, "overloaded");
1014
1015 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"}}"#;
1017 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1018 let ClaudeOutput::ToolProgress(msg) = &output else {
1019 panic!("expected ToolProgress");
1020 };
1021 assert_eq!(msg.subagent_retry.as_ref().unwrap().error_status, None);
1022 }
1023
1024 #[test]
1025 fn test_deserialize_transcript_only_message_types() {
1026 let cases = [
1027 (
1028 r#"{"type":"progress","sessionId":"s1","content":"delta"}"#,
1029 "progress",
1030 Some("s1"),
1031 ),
1032 (
1033 r#"{"type":"queue-operation","sessionId":"s2","operation":"push"}"#,
1034 "queue-operation",
1035 Some("s2"),
1036 ),
1037 (
1038 r#"{"type":"pr-link","sessionId":"s3","url":"https://example.invalid/pr"}"#,
1039 "pr-link",
1040 Some("s3"),
1041 ),
1042 (
1043 r#"{"type":"file-history-snapshot","sessionId":"s4","files":[]}"#,
1044 "file-history-snapshot",
1045 Some("s4"),
1046 ),
1047 (r#"{"type":"summary","summary":"Done"}"#, "summary", None),
1048 (
1049 r#"{"type":"mode","mode":"normal","sessionId":"s5"}"#,
1050 "mode",
1051 Some("s5"),
1052 ),
1053 (
1054 r#"{"type":"permission-mode","permissionMode":"default","sessionId":"s6"}"#,
1055 "permission-mode",
1056 Some("s6"),
1057 ),
1058 (
1059 r#"{"type":"attachment","attachment":{"type":"task_reminder"},"sessionId":"s7"}"#,
1060 "attachment",
1061 Some("s7"),
1062 ),
1063 (
1064 r#"{"type":"ai-title","aiTitle":"Title","sessionId":"s8"}"#,
1065 "ai-title",
1066 Some("s8"),
1067 ),
1068 (
1069 r#"{"type":"last-prompt","lastPrompt":"prompt","sessionId":"s9"}"#,
1070 "last-prompt",
1071 Some("s9"),
1072 ),
1073 (
1074 r#"{"type":"started","sessionId":"s10"}"#,
1075 "started",
1076 Some("s10"),
1077 ),
1078 (
1079 r#"{"type":"result","key":"k","agentId":"a","result":{"ok":true}}"#,
1080 "result",
1081 None,
1082 ),
1083 ];
1084
1085 for (json, message_type, session_id) in cases {
1086 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1087 assert_eq!(output.message_type(), message_type);
1088 assert_eq!(output.session_id(), session_id);
1089 }
1090 }
1091
1092 #[test]
1093 fn test_transcript_assistant_accepts_camel_case_session_id() {
1094 let json = r#"{
1095 "type": "assistant",
1096 "sessionId": "camel-session",
1097 "message": {
1098 "id": "msg_123",
1099 "role": "assistant",
1100 "model": "claude-3-sonnet",
1101 "content": [{"type": "text", "text": "Hello"}]
1102 }
1103 }"#;
1104
1105 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1106 assert_eq!(output.session_id(), Some("camel-session"));
1107 }
1108
1109 #[test]
1110 fn test_is_system_init() {
1111 let init_json = r#"{
1112 "type": "system",
1113 "subtype": "init",
1114 "session_id": "test-session"
1115 }"#;
1116 let output: ClaudeOutput = serde_json::from_str(init_json).unwrap();
1117 assert!(output.is_system_init());
1118
1119 let status_json = r#"{
1120 "type": "system",
1121 "subtype": "status",
1122 "session_id": "test-session"
1123 }"#;
1124 let output: ClaudeOutput = serde_json::from_str(status_json).unwrap();
1125 assert!(!output.is_system_init());
1126 }
1127
1128 #[test]
1129 fn test_session_id() {
1130 let result_json = r#"{
1132 "type": "result",
1133 "subtype": "success",
1134 "is_error": false,
1135 "duration_ms": 100,
1136 "duration_api_ms": 200,
1137 "num_turns": 1,
1138 "session_id": "result-session",
1139 "total_cost_usd": 0.01
1140 }"#;
1141 let output: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1142 assert_eq!(output.session_id(), Some("result-session"));
1143
1144 let assistant_json = r#"{
1146 "type": "assistant",
1147 "message": {
1148 "id": "msg_1",
1149 "role": "assistant",
1150 "model": "claude-3",
1151 "content": []
1152 },
1153 "session_id": "assistant-session"
1154 }"#;
1155 let output: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
1156 assert_eq!(output.session_id(), Some("assistant-session"));
1157
1158 let system_json = r#"{
1160 "type": "system",
1161 "subtype": "init",
1162 "session_id": "system-session"
1163 }"#;
1164 let output: ClaudeOutput = serde_json::from_str(system_json).unwrap();
1165 assert_eq!(output.session_id(), Some("system-session"));
1166 }
1167
1168 #[test]
1169 fn test_as_tool_use() {
1170 let json = r#"{
1171 "type": "assistant",
1172 "message": {
1173 "id": "msg_1",
1174 "role": "assistant",
1175 "model": "claude-3",
1176 "content": [
1177 {"type": "text", "text": "Let me run that command."},
1178 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls -la"}},
1179 {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/test"}}
1180 ]
1181 },
1182 "session_id": "abc"
1183 }"#;
1184 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1185
1186 let bash = output.as_tool_use("Bash");
1188 assert!(bash.is_some());
1189 assert_eq!(bash.unwrap().id, "tu_1");
1190
1191 let read = output.as_tool_use("Read");
1193 assert!(read.is_some());
1194 assert_eq!(read.unwrap().id, "tu_2");
1195
1196 assert!(output.as_tool_use("Write").is_none());
1198
1199 let result_json = r#"{
1201 "type": "result",
1202 "subtype": "success",
1203 "is_error": false,
1204 "duration_ms": 100,
1205 "duration_api_ms": 200,
1206 "num_turns": 1,
1207 "session_id": "abc",
1208 "total_cost_usd": 0.01
1209 }"#;
1210 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1211 assert!(result.as_tool_use("Bash").is_none());
1212 }
1213
1214 #[test]
1215 fn test_tool_uses() {
1216 let json = r#"{
1217 "type": "assistant",
1218 "message": {
1219 "id": "msg_1",
1220 "role": "assistant",
1221 "model": "claude-3",
1222 "content": [
1223 {"type": "text", "text": "Running commands..."},
1224 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls"}},
1225 {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/a"}},
1226 {"type": "tool_use", "id": "tu_3", "name": "Write", "input": {"file_path": "/tmp/b", "content": "x"}}
1227 ]
1228 },
1229 "session_id": "abc"
1230 }"#;
1231 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1232
1233 let tools: Vec<_> = output.tool_uses().collect();
1234 assert_eq!(tools.len(), 3);
1235 assert_eq!(tools[0].name, "Bash");
1236 assert_eq!(tools[1].name, "Read");
1237 assert_eq!(tools[2].name, "Write");
1238 }
1239
1240 #[test]
1241 fn test_text_content() {
1242 let json = r#"{
1244 "type": "assistant",
1245 "message": {
1246 "id": "msg_1",
1247 "role": "assistant",
1248 "model": "claude-3",
1249 "content": [{"type": "text", "text": "Hello, world!"}]
1250 },
1251 "session_id": "abc"
1252 }"#;
1253 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1254 assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
1255
1256 let json = r#"{
1258 "type": "assistant",
1259 "message": {
1260 "id": "msg_1",
1261 "role": "assistant",
1262 "model": "claude-3",
1263 "content": [
1264 {"type": "text", "text": "Hello, "},
1265 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}},
1266 {"type": "text", "text": "world!"}
1267 ]
1268 },
1269 "session_id": "abc"
1270 }"#;
1271 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1272 assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
1273
1274 let json = r#"{
1276 "type": "assistant",
1277 "message": {
1278 "id": "msg_1",
1279 "role": "assistant",
1280 "model": "claude-3",
1281 "content": [{"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}}]
1282 },
1283 "session_id": "abc"
1284 }"#;
1285 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1286 assert_eq!(output.text_content(), None);
1287
1288 let json = r#"{
1290 "type": "result",
1291 "subtype": "success",
1292 "is_error": false,
1293 "duration_ms": 100,
1294 "duration_api_ms": 200,
1295 "num_turns": 1,
1296 "session_id": "abc",
1297 "total_cost_usd": 0.01
1298 }"#;
1299 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1300 assert_eq!(output.text_content(), None);
1301 }
1302
1303 #[test]
1304 fn test_as_assistant() {
1305 let json = r#"{
1306 "type": "assistant",
1307 "message": {
1308 "id": "msg_1",
1309 "role": "assistant",
1310 "model": "claude-sonnet-4",
1311 "content": []
1312 },
1313 "session_id": "abc"
1314 }"#;
1315 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1316
1317 let assistant = output.as_assistant();
1318 assert!(assistant.is_some());
1319 assert_eq!(assistant.unwrap().message.model, "claude-sonnet-4");
1320
1321 let result_json = r#"{
1323 "type": "result",
1324 "subtype": "success",
1325 "is_error": false,
1326 "duration_ms": 100,
1327 "duration_api_ms": 200,
1328 "num_turns": 1,
1329 "session_id": "abc",
1330 "total_cost_usd": 0.01
1331 }"#;
1332 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1333 assert!(result.as_assistant().is_none());
1334 }
1335
1336 #[test]
1337 fn test_as_result() {
1338 let json = r#"{
1339 "type": "result",
1340 "subtype": "success",
1341 "is_error": false,
1342 "duration_ms": 100,
1343 "duration_api_ms": 200,
1344 "num_turns": 5,
1345 "session_id": "abc",
1346 "total_cost_usd": 0.05
1347 }"#;
1348 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1349
1350 let result = output.as_result();
1351 assert!(result.is_some());
1352 assert_eq!(result.unwrap().num_turns, 5);
1353 assert_eq!(result.unwrap().total_cost_usd, 0.05);
1354
1355 let assistant_json = r#"{
1357 "type": "assistant",
1358 "message": {
1359 "id": "msg_1",
1360 "role": "assistant",
1361 "model": "claude-3",
1362 "content": []
1363 },
1364 "session_id": "abc"
1365 }"#;
1366 let assistant: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
1367 assert!(assistant.as_result().is_none());
1368 }
1369
1370 #[test]
1371 fn test_as_system() {
1372 let json = r#"{
1373 "type": "system",
1374 "subtype": "init",
1375 "session_id": "abc",
1376 "model": "claude-3"
1377 }"#;
1378 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1379
1380 let system = output.as_system();
1381 assert!(system.is_some());
1382 assert!(system.unwrap().is_init());
1383
1384 let result_json = r#"{
1386 "type": "result",
1387 "subtype": "success",
1388 "is_error": false,
1389 "duration_ms": 100,
1390 "duration_api_ms": 200,
1391 "num_turns": 1,
1392 "session_id": "abc",
1393 "total_cost_usd": 0.01
1394 }"#;
1395 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1396 assert!(result.as_system().is_none());
1397 }
1398}