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}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158pub struct AbandonedBlocks {
159 pub api_message_id: String,
163 pub from_block_index: u64,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct ToolProgressMessage {
171 pub tool_use_id: String,
172 pub tool_name: String,
173 pub parent_tool_use_id: Option<String>,
174 pub elapsed_time_seconds: f64,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 pub task_id: Option<String>,
177 pub uuid: String,
178 pub session_id: String,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub heartbeat: Option<bool>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub subagent_type: Option<String>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub subagent_retry: Option<SubagentRetry>,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct SubagentRetry {
195 pub agent_id: String,
196 pub attempt: u64,
197 pub max_retries: u64,
198 pub retry_delay_ms: u64,
199 pub error_status: Option<u16>,
200 pub error_category: String,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct CommandLifecycleMessage {
212 pub command_uuid: String,
215 pub state: CommandLifecycleState,
216 pub uuid: String,
217 pub session_id: String,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Hash)]
222pub enum CommandLifecycleState {
223 Queued,
225 Started,
227 Completed,
229 Cancelled,
232 Discarded,
234 Unknown(String),
236}
237
238impl CommandLifecycleState {
239 pub fn as_str(&self) -> &str {
240 match self {
241 Self::Queued => "queued",
242 Self::Started => "started",
243 Self::Completed => "completed",
244 Self::Cancelled => "cancelled",
245 Self::Discarded => "discarded",
246 Self::Unknown(s) => s.as_str(),
247 }
248 }
249}
250
251impl std::fmt::Display for CommandLifecycleState {
252 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253 f.write_str(self.as_str())
254 }
255}
256
257impl From<&str> for CommandLifecycleState {
258 fn from(s: &str) -> Self {
259 match s {
260 "queued" => Self::Queued,
261 "started" => Self::Started,
262 "completed" => Self::Completed,
263 "cancelled" => Self::Cancelled,
264 "discarded" => Self::Discarded,
265 other => Self::Unknown(other.to_string()),
266 }
267 }
268}
269
270impl Serialize for CommandLifecycleState {
271 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
272 serializer.serialize_str(self.as_str())
273 }
274}
275
276impl<'de> Deserialize<'de> for CommandLifecycleState {
277 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
278 let s = String::deserialize(deserializer)?;
279 Ok(Self::from(s.as_str()))
280 }
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct AuthStatusMessage {
285 #[serde(rename = "isAuthenticating")]
286 pub is_authenticating: bool,
287 pub output: Vec<String>,
288 #[serde(skip_serializing_if = "Option::is_none")]
289 pub error: Option<String>,
290 pub uuid: String,
291 pub session_id: String,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct ToolUseSummaryMessage {
296 pub summary: String,
297 pub preceding_tool_use_ids: Vec<String>,
298 pub uuid: String,
299 pub session_id: String,
300 #[serde(skip_serializing_if = "Option::is_none")]
301 pub timestamp: Option<String>,
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct PromptSuggestionMessage {
306 pub suggestion: String,
307 pub uuid: String,
308 pub session_id: String,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct ConversationResetMessage {
330 pub new_conversation_id: String,
334 pub uuid: String,
335 pub session_id: String,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub trigger: Option<ConversationResetTrigger>,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub user_message_uuid: Option<String>,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub timestamp: Option<String>,
357}
358
359#[derive(Debug, Clone, PartialEq, Eq, Hash)]
362pub enum ConversationResetTrigger {
363 Clear,
365 PlanModeExit,
367 FreshSession,
369 Onboarding,
371 Unknown(String),
373}
374
375impl ConversationResetTrigger {
376 pub fn as_str(&self) -> &str {
377 match self {
378 Self::Clear => "clear",
379 Self::PlanModeExit => "plan_mode_exit",
380 Self::FreshSession => "fresh_session",
381 Self::Onboarding => "onboarding",
382 Self::Unknown(s) => s.as_str(),
383 }
384 }
385}
386
387impl std::fmt::Display for ConversationResetTrigger {
388 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
389 f.write_str(self.as_str())
390 }
391}
392
393impl From<&str> for ConversationResetTrigger {
394 fn from(s: &str) -> Self {
395 match s {
396 "clear" => Self::Clear,
397 "plan_mode_exit" => Self::PlanModeExit,
398 "fresh_session" => Self::FreshSession,
399 "onboarding" => Self::Onboarding,
400 other => Self::Unknown(other.to_string()),
401 }
402 }
403}
404
405impl Serialize for ConversationResetTrigger {
406 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
407 serializer.serialize_str(self.as_str())
408 }
409}
410
411impl<'de> Deserialize<'de> for ConversationResetTrigger {
412 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
413 let s = String::deserialize(deserializer)?;
414 Ok(Self::from(s.as_str()))
415 }
416}
417
418#[derive(Debug, Clone, Serialize, Deserialize)]
425pub struct TranscriptMessage {
426 #[serde(flatten)]
427 pub data: Map<String, Value>,
428}
429
430impl ClaudeOutput {
431 pub fn message_type(&self) -> String {
433 match self {
434 ClaudeOutput::System(_) => "system".to_string(),
435 ClaudeOutput::User(_) => "user".to_string(),
436 ClaudeOutput::Assistant(_) => "assistant".to_string(),
437 ClaudeOutput::Result(_) => "result".to_string(),
438 ClaudeOutput::TranscriptResult(_) => "result".to_string(),
439 ClaudeOutput::ControlRequest(_) => "control_request".to_string(),
440 ClaudeOutput::ControlResponse(_) => "control_response".to_string(),
441 ClaudeOutput::Error(_) => "error".to_string(),
442 ClaudeOutput::RateLimitEvent(_) => "rate_limit_event".to_string(),
443 ClaudeOutput::StreamEvent(_) => "stream_event".to_string(),
444 ClaudeOutput::ToolProgress(_) => "tool_progress".to_string(),
445 ClaudeOutput::CommandLifecycle(_) => "command_lifecycle".to_string(),
446 ClaudeOutput::AuthStatus(_) => "auth_status".to_string(),
447 ClaudeOutput::ToolUseSummary(_) => "tool_use_summary".to_string(),
448 ClaudeOutput::PromptSuggestion(_) => "prompt_suggestion".to_string(),
449 ClaudeOutput::ConversationReset(_) => "conversation_reset".to_string(),
450 ClaudeOutput::Progress(_) => "progress".to_string(),
451 ClaudeOutput::QueueOperation(_) => "queue-operation".to_string(),
452 ClaudeOutput::PrLink(_) => "pr-link".to_string(),
453 ClaudeOutput::FileHistorySnapshot(_) => "file-history-snapshot".to_string(),
454 ClaudeOutput::Summary(_) => "summary".to_string(),
455 ClaudeOutput::Mode(_) => "mode".to_string(),
456 ClaudeOutput::PermissionMode(_) => "permission-mode".to_string(),
457 ClaudeOutput::Attachment(_) => "attachment".to_string(),
458 ClaudeOutput::AiTitle(_) => "ai-title".to_string(),
459 ClaudeOutput::LastPrompt(_) => "last-prompt".to_string(),
460 ClaudeOutput::Started(_) => "started".to_string(),
461 }
462 }
463
464 pub fn is_control_request(&self) -> bool {
466 matches!(self, ClaudeOutput::ControlRequest(_))
467 }
468
469 pub fn is_control_response(&self) -> bool {
471 matches!(self, ClaudeOutput::ControlResponse(_))
472 }
473
474 pub fn is_api_error(&self) -> bool {
476 matches!(self, ClaudeOutput::Error(_))
477 }
478
479 pub fn as_control_request(&self) -> Option<&ControlRequest> {
481 match self {
482 ClaudeOutput::ControlRequest(req) => Some(req),
483 _ => None,
484 }
485 }
486
487 pub fn as_anthropic_error(&self) -> Option<&AnthropicError> {
503 match self {
504 ClaudeOutput::Error(err) => Some(err),
505 _ => None,
506 }
507 }
508
509 pub fn is_rate_limit_event(&self) -> bool {
511 matches!(self, ClaudeOutput::RateLimitEvent(_))
512 }
513
514 pub fn as_rate_limit_event(&self) -> Option<&RateLimitEvent> {
516 match self {
517 ClaudeOutput::RateLimitEvent(evt) => Some(evt),
518 _ => None,
519 }
520 }
521
522 pub fn is_error(&self) -> bool {
524 matches!(self, ClaudeOutput::Result(r) if r.is_error)
525 }
526
527 pub fn is_assistant_message(&self) -> bool {
529 matches!(self, ClaudeOutput::Assistant(_))
530 }
531
532 pub fn is_system_message(&self) -> bool {
534 matches!(self, ClaudeOutput::System(_))
535 }
536
537 pub fn is_system_init(&self) -> bool {
548 matches!(self, ClaudeOutput::System(sys) if sys.is_init())
549 }
550
551 pub fn session_id(&self) -> Option<&str> {
567 match self {
568 ClaudeOutput::System(sys) => sys
569 .data
570 .get("session_id")
571 .or_else(|| sys.data.get("sessionId"))
572 .and_then(|v| v.as_str()),
573 ClaudeOutput::Assistant(ass) => Some(&ass.session_id),
574 ClaudeOutput::Result(res) => Some(&res.session_id),
575 ClaudeOutput::TranscriptResult(msg) => msg
576 .data
577 .get("session_id")
578 .or_else(|| msg.data.get("sessionId"))
579 .and_then(|v| v.as_str()),
580 ClaudeOutput::User(_) => None,
581 ClaudeOutput::ControlRequest(_) => None,
582 ClaudeOutput::ControlResponse(_) => None,
583 ClaudeOutput::Error(_) => None,
584 ClaudeOutput::RateLimitEvent(evt) => Some(&evt.session_id),
585 ClaudeOutput::StreamEvent(msg) => Some(&msg.session_id),
586 ClaudeOutput::ToolProgress(msg) => Some(&msg.session_id),
587 ClaudeOutput::CommandLifecycle(msg) => Some(&msg.session_id),
588 ClaudeOutput::AuthStatus(msg) => Some(&msg.session_id),
589 ClaudeOutput::ToolUseSummary(msg) => Some(&msg.session_id),
590 ClaudeOutput::PromptSuggestion(msg) => Some(&msg.session_id),
591 ClaudeOutput::ConversationReset(msg) => Some(&msg.session_id),
592 ClaudeOutput::Progress(msg)
593 | ClaudeOutput::QueueOperation(msg)
594 | ClaudeOutput::PrLink(msg)
595 | ClaudeOutput::FileHistorySnapshot(msg)
596 | ClaudeOutput::Summary(msg)
597 | ClaudeOutput::Mode(msg)
598 | ClaudeOutput::PermissionMode(msg)
599 | ClaudeOutput::Attachment(msg)
600 | ClaudeOutput::AiTitle(msg)
601 | ClaudeOutput::LastPrompt(msg)
602 | ClaudeOutput::Started(msg) => msg
603 .data
604 .get("session_id")
605 .or_else(|| msg.data.get("sessionId"))
606 .and_then(|v| v.as_str()),
607 }
608 }
609
610 pub fn as_tool_use(&self, tool_name: &str) -> Option<&ToolUseBlock> {
629 match self {
630 ClaudeOutput::Assistant(ass) => {
631 ass.message.content.iter().find_map(|block| match block {
632 ContentBlock::ToolUse(tu) if tu.name == tool_name => Some(tu),
633 _ => None,
634 })
635 }
636 _ => None,
637 }
638 }
639
640 pub fn tool_uses(&self) -> impl Iterator<Item = &ToolUseBlock> {
660 let content = match self {
661 ClaudeOutput::Assistant(ass) => Some(&ass.message.content),
662 _ => None,
663 };
664
665 content
666 .into_iter()
667 .flat_map(|c| c.iter())
668 .filter_map(|block| match block {
669 ContentBlock::ToolUse(tu) => Some(tu),
670 _ => None,
671 })
672 }
673
674 pub fn text_content(&self) -> Option<String> {
690 match self {
691 ClaudeOutput::Assistant(ass) => {
692 let texts: Vec<&str> = ass
693 .message
694 .content
695 .iter()
696 .filter_map(|block| match block {
697 ContentBlock::Text(t) => Some(t.text.as_str()),
698 _ => None,
699 })
700 .collect();
701
702 if texts.is_empty() {
703 None
704 } else {
705 Some(texts.join(""))
706 }
707 }
708 _ => None,
709 }
710 }
711
712 pub fn as_assistant(&self) -> Option<&AssistantMessage> {
727 match self {
728 ClaudeOutput::Assistant(ass) => Some(ass),
729 _ => None,
730 }
731 }
732
733 pub fn as_result(&self) -> Option<&ResultMessage> {
749 match self {
750 ClaudeOutput::Result(res) => Some(res),
751 _ => None,
752 }
753 }
754
755 pub fn as_system(&self) -> Option<&SystemMessage> {
757 match self {
758 ClaudeOutput::System(sys) => Some(sys),
759 _ => None,
760 }
761 }
762
763 pub fn parse_json_tolerant(s: &str) -> Result<ClaudeOutput, ParseError> {
768 match Self::parse_json(s) {
770 Ok(output) => Ok(output),
771 Err(first_error) => {
772 if let Some(json_start) = s.find('{') {
774 let trimmed = &s[json_start..];
775 match Self::parse_json(trimmed) {
776 Ok(output) => Ok(output),
777 Err(_) => {
778 Err(first_error)
780 }
781 }
782 } else {
783 Err(first_error)
784 }
785 }
786 }
787 }
788
789 pub fn parse_json(s: &str) -> Result<ClaudeOutput, ParseError> {
791 let value: Value = serde_json::from_str(s).map_err(|e| ParseError {
793 raw_line: s.to_string(),
794 raw_json: None,
795 error_message: format!("Invalid JSON: {}", e),
796 })?;
797
798 serde_json::from_value::<ClaudeOutput>(value.clone()).map_err(|e| ParseError {
800 raw_line: s.to_string(),
801 raw_json: Some(value),
802 error_message: e.to_string(),
803 })
804 }
805}
806
807impl<'de> Deserialize<'de> for ClaudeOutput {
808 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
809 let value = Value::deserialize(deserializer)?;
810 let message_type = value
811 .get("type")
812 .and_then(|v| v.as_str())
813 .ok_or_else(|| serde::de::Error::missing_field("type"))?;
814 let mut payload = value.clone();
815 if let Some(obj) = payload.as_object_mut() {
816 obj.remove("type");
817 }
818
819 fn parse<T, E>(value: Value) -> Result<T, E>
820 where
821 T: serde::de::DeserializeOwned,
822 E: serde::de::Error,
823 {
824 serde_json::from_value(value).map_err(E::custom)
825 }
826
827 match message_type {
828 "system" => parse(payload).map(Self::System),
829 "user" => parse(payload).map(Self::User),
830 "assistant" => parse(payload).map(Self::Assistant),
831 "result" if value.get("subtype").is_some() => parse(payload).map(Self::Result),
832 "result" => parse(payload).map(Self::TranscriptResult),
833 "control_request" => parse(payload).map(Self::ControlRequest),
834 "control_response" => parse(payload).map(Self::ControlResponse),
835 "error" => parse(payload).map(Self::Error),
836 "rate_limit_event" => parse(payload).map(Self::RateLimitEvent),
837 "stream_event" => parse(payload).map(Self::StreamEvent),
838 "tool_progress" => parse(payload).map(Self::ToolProgress),
839 "command_lifecycle" => parse(payload).map(Self::CommandLifecycle),
840 "auth_status" => parse(payload).map(Self::AuthStatus),
841 "tool_use_summary" => parse(payload).map(Self::ToolUseSummary),
842 "prompt_suggestion" => parse(payload).map(Self::PromptSuggestion),
843 "conversation_reset" => parse(payload).map(Self::ConversationReset),
844 "progress" => parse(payload).map(Self::Progress),
845 "queue-operation" => parse(payload).map(Self::QueueOperation),
846 "pr-link" => parse(payload).map(Self::PrLink),
847 "file-history-snapshot" => parse(payload).map(Self::FileHistorySnapshot),
848 "summary" => parse(payload).map(Self::Summary),
849 "mode" => parse(payload).map(Self::Mode),
850 "permission-mode" => parse(payload).map(Self::PermissionMode),
851 "attachment" => parse(payload).map(Self::Attachment),
852 "ai-title" => parse(payload).map(Self::AiTitle),
853 "last-prompt" => parse(payload).map(Self::LastPrompt),
854 "started" => parse(payload).map(Self::Started),
855 other => Err(serde::de::Error::unknown_variant(
856 other,
857 &[
858 "system",
859 "user",
860 "assistant",
861 "result",
862 "control_request",
863 "control_response",
864 "error",
865 "rate_limit_event",
866 "stream_event",
867 "tool_progress",
868 "command_lifecycle",
869 "auth_status",
870 "tool_use_summary",
871 "prompt_suggestion",
872 "conversation_reset",
873 "progress",
874 "queue-operation",
875 "pr-link",
876 "file-history-snapshot",
877 "summary",
878 "mode",
879 "permission-mode",
880 "attachment",
881 "ai-title",
882 "last-prompt",
883 "started",
884 ],
885 )),
886 }
887 }
888}
889
890#[cfg(test)]
891mod tests {
892 use super::*;
893
894 #[test]
895 fn test_deserialize_assistant_message() {
896 let json = r#"{
897 "type": "assistant",
898 "message": {
899 "id": "msg_123",
900 "role": "assistant",
901 "model": "claude-3-sonnet",
902 "content": [{"type": "text", "text": "Hello! How can I help you?"}]
903 },
904 "session_id": "123"
905 }"#;
906
907 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
908 assert!(output.is_assistant_message());
909 }
910
911 #[test]
912 fn test_deserialize_new_top_level_message_types() {
913 let cases = [
914 (
915 r#"{"type":"stream_event","event":{"type":"content_block_delta"},"parent_tool_use_id":null,"uuid":"u1","session_id":"s1","ttft_ms":12}"#,
916 "stream_event",
917 ),
918 (
919 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"}"#,
920 "tool_progress",
921 ),
922 (
923 r#"{"type":"auth_status","isAuthenticating":true,"output":["login"],"uuid":"u3","session_id":"s3"}"#,
924 "auth_status",
925 ),
926 (
927 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"}"#,
928 "tool_use_summary",
929 ),
930 (
931 r#"{"type":"prompt_suggestion","suggestion":"Run tests","uuid":"u5","session_id":"s5"}"#,
932 "prompt_suggestion",
933 ),
934 (
935 r#"{"type":"conversation_reset","new_conversation_id":"new-session","uuid":"u6","session_id":"s6"}"#,
936 "conversation_reset",
937 ),
938 (
939 r#"{"type":"command_lifecycle","command_uuid":"cmd-1","state":"queued","uuid":"u7","session_id":"s7"}"#,
940 "command_lifecycle",
941 ),
942 ];
943
944 for (json, message_type) in cases {
945 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
946 assert_eq!(output.message_type(), message_type);
947 assert!(output.session_id().is_some());
948 }
949 }
950
951 #[test]
952 fn test_command_lifecycle_states_roundtrip() {
953 for state in ["queued", "started", "completed", "cancelled", "discarded"] {
954 let json = format!(
955 r#"{{"type":"command_lifecycle","command_uuid":"cmd-1","state":"{}","uuid":"u1","session_id":"s1"}}"#,
956 state
957 );
958 let output: ClaudeOutput = serde_json::from_str(&json).unwrap();
959 let ClaudeOutput::CommandLifecycle(msg) = &output else {
960 panic!("expected CommandLifecycle");
961 };
962 assert_eq!(msg.state.as_str(), state);
963 assert!(!matches!(msg.state, CommandLifecycleState::Unknown(_)));
964 assert_eq!(serde_json::to_string(&output).unwrap(), json);
965 }
966
967 let json = r#"{"type":"command_lifecycle","command_uuid":"cmd-2","state":"parked","uuid":"u2","session_id":"s2"}"#;
969 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
970 let ClaudeOutput::CommandLifecycle(msg) = &output else {
971 panic!("expected CommandLifecycle");
972 };
973 assert_eq!(
974 msg.state,
975 CommandLifecycleState::Unknown("parked".to_string())
976 );
977 assert_eq!(serde_json::to_string(&output).unwrap(), json);
978 }
979
980 #[test]
981 fn test_tool_progress_heartbeat_and_subagent_retry() {
982 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"}}"#;
983 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
984 let ClaudeOutput::ToolProgress(msg) = &output else {
985 panic!("expected ToolProgress");
986 };
987 assert_eq!(msg.heartbeat, Some(true));
988 assert_eq!(msg.subagent_type.as_deref(), Some("Explore"));
989 let retry = msg.subagent_retry.as_ref().unwrap();
990 assert_eq!(retry.attempt, 2);
991 assert_eq!(retry.error_status, Some(529));
992 assert_eq!(retry.error_category, "overloaded");
993
994 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"}}"#;
996 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
997 let ClaudeOutput::ToolProgress(msg) = &output else {
998 panic!("expected ToolProgress");
999 };
1000 assert_eq!(msg.subagent_retry.as_ref().unwrap().error_status, None);
1001 }
1002
1003 #[test]
1004 fn test_deserialize_transcript_only_message_types() {
1005 let cases = [
1006 (
1007 r#"{"type":"progress","sessionId":"s1","content":"delta"}"#,
1008 "progress",
1009 Some("s1"),
1010 ),
1011 (
1012 r#"{"type":"queue-operation","sessionId":"s2","operation":"push"}"#,
1013 "queue-operation",
1014 Some("s2"),
1015 ),
1016 (
1017 r#"{"type":"pr-link","sessionId":"s3","url":"https://example.invalid/pr"}"#,
1018 "pr-link",
1019 Some("s3"),
1020 ),
1021 (
1022 r#"{"type":"file-history-snapshot","sessionId":"s4","files":[]}"#,
1023 "file-history-snapshot",
1024 Some("s4"),
1025 ),
1026 (r#"{"type":"summary","summary":"Done"}"#, "summary", None),
1027 (
1028 r#"{"type":"mode","mode":"normal","sessionId":"s5"}"#,
1029 "mode",
1030 Some("s5"),
1031 ),
1032 (
1033 r#"{"type":"permission-mode","permissionMode":"default","sessionId":"s6"}"#,
1034 "permission-mode",
1035 Some("s6"),
1036 ),
1037 (
1038 r#"{"type":"attachment","attachment":{"type":"task_reminder"},"sessionId":"s7"}"#,
1039 "attachment",
1040 Some("s7"),
1041 ),
1042 (
1043 r#"{"type":"ai-title","aiTitle":"Title","sessionId":"s8"}"#,
1044 "ai-title",
1045 Some("s8"),
1046 ),
1047 (
1048 r#"{"type":"last-prompt","lastPrompt":"prompt","sessionId":"s9"}"#,
1049 "last-prompt",
1050 Some("s9"),
1051 ),
1052 (
1053 r#"{"type":"started","sessionId":"s10"}"#,
1054 "started",
1055 Some("s10"),
1056 ),
1057 (
1058 r#"{"type":"result","key":"k","agentId":"a","result":{"ok":true}}"#,
1059 "result",
1060 None,
1061 ),
1062 ];
1063
1064 for (json, message_type, session_id) in cases {
1065 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1066 assert_eq!(output.message_type(), message_type);
1067 assert_eq!(output.session_id(), session_id);
1068 }
1069 }
1070
1071 #[test]
1072 fn test_transcript_assistant_accepts_camel_case_session_id() {
1073 let json = r#"{
1074 "type": "assistant",
1075 "sessionId": "camel-session",
1076 "message": {
1077 "id": "msg_123",
1078 "role": "assistant",
1079 "model": "claude-3-sonnet",
1080 "content": [{"type": "text", "text": "Hello"}]
1081 }
1082 }"#;
1083
1084 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1085 assert_eq!(output.session_id(), Some("camel-session"));
1086 }
1087
1088 #[test]
1089 fn test_is_system_init() {
1090 let init_json = r#"{
1091 "type": "system",
1092 "subtype": "init",
1093 "session_id": "test-session"
1094 }"#;
1095 let output: ClaudeOutput = serde_json::from_str(init_json).unwrap();
1096 assert!(output.is_system_init());
1097
1098 let status_json = r#"{
1099 "type": "system",
1100 "subtype": "status",
1101 "session_id": "test-session"
1102 }"#;
1103 let output: ClaudeOutput = serde_json::from_str(status_json).unwrap();
1104 assert!(!output.is_system_init());
1105 }
1106
1107 #[test]
1108 fn test_session_id() {
1109 let result_json = r#"{
1111 "type": "result",
1112 "subtype": "success",
1113 "is_error": false,
1114 "duration_ms": 100,
1115 "duration_api_ms": 200,
1116 "num_turns": 1,
1117 "session_id": "result-session",
1118 "total_cost_usd": 0.01
1119 }"#;
1120 let output: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1121 assert_eq!(output.session_id(), Some("result-session"));
1122
1123 let assistant_json = r#"{
1125 "type": "assistant",
1126 "message": {
1127 "id": "msg_1",
1128 "role": "assistant",
1129 "model": "claude-3",
1130 "content": []
1131 },
1132 "session_id": "assistant-session"
1133 }"#;
1134 let output: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
1135 assert_eq!(output.session_id(), Some("assistant-session"));
1136
1137 let system_json = r#"{
1139 "type": "system",
1140 "subtype": "init",
1141 "session_id": "system-session"
1142 }"#;
1143 let output: ClaudeOutput = serde_json::from_str(system_json).unwrap();
1144 assert_eq!(output.session_id(), Some("system-session"));
1145 }
1146
1147 #[test]
1148 fn test_as_tool_use() {
1149 let json = r#"{
1150 "type": "assistant",
1151 "message": {
1152 "id": "msg_1",
1153 "role": "assistant",
1154 "model": "claude-3",
1155 "content": [
1156 {"type": "text", "text": "Let me run that command."},
1157 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls -la"}},
1158 {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/test"}}
1159 ]
1160 },
1161 "session_id": "abc"
1162 }"#;
1163 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1164
1165 let bash = output.as_tool_use("Bash");
1167 assert!(bash.is_some());
1168 assert_eq!(bash.unwrap().id, "tu_1");
1169
1170 let read = output.as_tool_use("Read");
1172 assert!(read.is_some());
1173 assert_eq!(read.unwrap().id, "tu_2");
1174
1175 assert!(output.as_tool_use("Write").is_none());
1177
1178 let result_json = r#"{
1180 "type": "result",
1181 "subtype": "success",
1182 "is_error": false,
1183 "duration_ms": 100,
1184 "duration_api_ms": 200,
1185 "num_turns": 1,
1186 "session_id": "abc",
1187 "total_cost_usd": 0.01
1188 }"#;
1189 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1190 assert!(result.as_tool_use("Bash").is_none());
1191 }
1192
1193 #[test]
1194 fn test_tool_uses() {
1195 let json = r#"{
1196 "type": "assistant",
1197 "message": {
1198 "id": "msg_1",
1199 "role": "assistant",
1200 "model": "claude-3",
1201 "content": [
1202 {"type": "text", "text": "Running commands..."},
1203 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls"}},
1204 {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/a"}},
1205 {"type": "tool_use", "id": "tu_3", "name": "Write", "input": {"file_path": "/tmp/b", "content": "x"}}
1206 ]
1207 },
1208 "session_id": "abc"
1209 }"#;
1210 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1211
1212 let tools: Vec<_> = output.tool_uses().collect();
1213 assert_eq!(tools.len(), 3);
1214 assert_eq!(tools[0].name, "Bash");
1215 assert_eq!(tools[1].name, "Read");
1216 assert_eq!(tools[2].name, "Write");
1217 }
1218
1219 #[test]
1220 fn test_text_content() {
1221 let json = r#"{
1223 "type": "assistant",
1224 "message": {
1225 "id": "msg_1",
1226 "role": "assistant",
1227 "model": "claude-3",
1228 "content": [{"type": "text", "text": "Hello, world!"}]
1229 },
1230 "session_id": "abc"
1231 }"#;
1232 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1233 assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
1234
1235 let json = r#"{
1237 "type": "assistant",
1238 "message": {
1239 "id": "msg_1",
1240 "role": "assistant",
1241 "model": "claude-3",
1242 "content": [
1243 {"type": "text", "text": "Hello, "},
1244 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}},
1245 {"type": "text", "text": "world!"}
1246 ]
1247 },
1248 "session_id": "abc"
1249 }"#;
1250 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1251 assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
1252
1253 let json = r#"{
1255 "type": "assistant",
1256 "message": {
1257 "id": "msg_1",
1258 "role": "assistant",
1259 "model": "claude-3",
1260 "content": [{"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}}]
1261 },
1262 "session_id": "abc"
1263 }"#;
1264 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1265 assert_eq!(output.text_content(), None);
1266
1267 let json = r#"{
1269 "type": "result",
1270 "subtype": "success",
1271 "is_error": false,
1272 "duration_ms": 100,
1273 "duration_api_ms": 200,
1274 "num_turns": 1,
1275 "session_id": "abc",
1276 "total_cost_usd": 0.01
1277 }"#;
1278 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1279 assert_eq!(output.text_content(), None);
1280 }
1281
1282 #[test]
1283 fn test_as_assistant() {
1284 let json = r#"{
1285 "type": "assistant",
1286 "message": {
1287 "id": "msg_1",
1288 "role": "assistant",
1289 "model": "claude-sonnet-4",
1290 "content": []
1291 },
1292 "session_id": "abc"
1293 }"#;
1294 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1295
1296 let assistant = output.as_assistant();
1297 assert!(assistant.is_some());
1298 assert_eq!(assistant.unwrap().message.model, "claude-sonnet-4");
1299
1300 let result_json = r#"{
1302 "type": "result",
1303 "subtype": "success",
1304 "is_error": false,
1305 "duration_ms": 100,
1306 "duration_api_ms": 200,
1307 "num_turns": 1,
1308 "session_id": "abc",
1309 "total_cost_usd": 0.01
1310 }"#;
1311 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1312 assert!(result.as_assistant().is_none());
1313 }
1314
1315 #[test]
1316 fn test_as_result() {
1317 let json = r#"{
1318 "type": "result",
1319 "subtype": "success",
1320 "is_error": false,
1321 "duration_ms": 100,
1322 "duration_api_ms": 200,
1323 "num_turns": 5,
1324 "session_id": "abc",
1325 "total_cost_usd": 0.05
1326 }"#;
1327 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1328
1329 let result = output.as_result();
1330 assert!(result.is_some());
1331 assert_eq!(result.unwrap().num_turns, 5);
1332 assert_eq!(result.unwrap().total_cost_usd, 0.05);
1333
1334 let assistant_json = r#"{
1336 "type": "assistant",
1337 "message": {
1338 "id": "msg_1",
1339 "role": "assistant",
1340 "model": "claude-3",
1341 "content": []
1342 },
1343 "session_id": "abc"
1344 }"#;
1345 let assistant: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
1346 assert!(assistant.as_result().is_none());
1347 }
1348
1349 #[test]
1350 fn test_as_system() {
1351 let json = r#"{
1352 "type": "system",
1353 "subtype": "init",
1354 "session_id": "abc",
1355 "model": "claude-3"
1356 }"#;
1357 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1358
1359 let system = output.as_system();
1360 assert!(system.is_some());
1361 assert!(system.unwrap().is_init());
1362
1363 let result_json = r#"{
1365 "type": "result",
1366 "subtype": "success",
1367 "is_error": false,
1368 "duration_ms": 100,
1369 "duration_api_ms": 200,
1370 "num_turns": 1,
1371 "session_id": "abc",
1372 "total_cost_usd": 0.01
1373 }"#;
1374 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1375 assert!(result.as_system().is_none());
1376 }
1377}