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}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct ToolProgressMessage {
149 pub tool_use_id: String,
150 pub tool_name: String,
151 pub parent_tool_use_id: Option<String>,
152 pub elapsed_time_seconds: f64,
153 #[serde(skip_serializing_if = "Option::is_none")]
154 pub task_id: Option<String>,
155 pub uuid: String,
156 pub session_id: String,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub heartbeat: Option<bool>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub subagent_type: Option<String>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub subagent_retry: Option<SubagentRetry>,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct SubagentRetry {
173 pub agent_id: String,
174 pub attempt: u64,
175 pub max_retries: u64,
176 pub retry_delay_ms: u64,
177 pub error_status: Option<u16>,
178 pub error_category: String,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct CommandLifecycleMessage {
190 pub command_uuid: String,
193 pub state: CommandLifecycleState,
194 pub uuid: String,
195 pub session_id: String,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Hash)]
200pub enum CommandLifecycleState {
201 Queued,
203 Started,
205 Completed,
207 Cancelled,
210 Discarded,
212 Unknown(String),
214}
215
216impl CommandLifecycleState {
217 pub fn as_str(&self) -> &str {
218 match self {
219 Self::Queued => "queued",
220 Self::Started => "started",
221 Self::Completed => "completed",
222 Self::Cancelled => "cancelled",
223 Self::Discarded => "discarded",
224 Self::Unknown(s) => s.as_str(),
225 }
226 }
227}
228
229impl std::fmt::Display for CommandLifecycleState {
230 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231 f.write_str(self.as_str())
232 }
233}
234
235impl From<&str> for CommandLifecycleState {
236 fn from(s: &str) -> Self {
237 match s {
238 "queued" => Self::Queued,
239 "started" => Self::Started,
240 "completed" => Self::Completed,
241 "cancelled" => Self::Cancelled,
242 "discarded" => Self::Discarded,
243 other => Self::Unknown(other.to_string()),
244 }
245 }
246}
247
248impl Serialize for CommandLifecycleState {
249 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
250 serializer.serialize_str(self.as_str())
251 }
252}
253
254impl<'de> Deserialize<'de> for CommandLifecycleState {
255 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
256 let s = String::deserialize(deserializer)?;
257 Ok(Self::from(s.as_str()))
258 }
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct AuthStatusMessage {
263 #[serde(rename = "isAuthenticating")]
264 pub is_authenticating: bool,
265 pub output: Vec<String>,
266 #[serde(skip_serializing_if = "Option::is_none")]
267 pub error: Option<String>,
268 pub uuid: String,
269 pub session_id: String,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct ToolUseSummaryMessage {
274 pub summary: String,
275 pub preceding_tool_use_ids: Vec<String>,
276 pub uuid: String,
277 pub session_id: String,
278 #[serde(skip_serializing_if = "Option::is_none")]
279 pub timestamp: Option<String>,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct PromptSuggestionMessage {
284 pub suggestion: String,
285 pub uuid: String,
286 pub session_id: String,
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct ConversationResetMessage {
308 pub new_conversation_id: String,
312 pub uuid: String,
313 pub session_id: String,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub trigger: Option<ConversationResetTrigger>,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub user_message_uuid: Option<String>,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub timestamp: Option<String>,
335}
336
337#[derive(Debug, Clone, PartialEq, Eq, Hash)]
340pub enum ConversationResetTrigger {
341 Clear,
343 PlanModeExit,
345 FreshSession,
347 Onboarding,
349 Unknown(String),
351}
352
353impl ConversationResetTrigger {
354 pub fn as_str(&self) -> &str {
355 match self {
356 Self::Clear => "clear",
357 Self::PlanModeExit => "plan_mode_exit",
358 Self::FreshSession => "fresh_session",
359 Self::Onboarding => "onboarding",
360 Self::Unknown(s) => s.as_str(),
361 }
362 }
363}
364
365impl std::fmt::Display for ConversationResetTrigger {
366 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367 f.write_str(self.as_str())
368 }
369}
370
371impl From<&str> for ConversationResetTrigger {
372 fn from(s: &str) -> Self {
373 match s {
374 "clear" => Self::Clear,
375 "plan_mode_exit" => Self::PlanModeExit,
376 "fresh_session" => Self::FreshSession,
377 "onboarding" => Self::Onboarding,
378 other => Self::Unknown(other.to_string()),
379 }
380 }
381}
382
383impl Serialize for ConversationResetTrigger {
384 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
385 serializer.serialize_str(self.as_str())
386 }
387}
388
389impl<'de> Deserialize<'de> for ConversationResetTrigger {
390 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
391 let s = String::deserialize(deserializer)?;
392 Ok(Self::from(s.as_str()))
393 }
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct TranscriptMessage {
404 #[serde(flatten)]
405 pub data: Map<String, Value>,
406}
407
408impl ClaudeOutput {
409 pub fn message_type(&self) -> String {
411 match self {
412 ClaudeOutput::System(_) => "system".to_string(),
413 ClaudeOutput::User(_) => "user".to_string(),
414 ClaudeOutput::Assistant(_) => "assistant".to_string(),
415 ClaudeOutput::Result(_) => "result".to_string(),
416 ClaudeOutput::TranscriptResult(_) => "result".to_string(),
417 ClaudeOutput::ControlRequest(_) => "control_request".to_string(),
418 ClaudeOutput::ControlResponse(_) => "control_response".to_string(),
419 ClaudeOutput::Error(_) => "error".to_string(),
420 ClaudeOutput::RateLimitEvent(_) => "rate_limit_event".to_string(),
421 ClaudeOutput::StreamEvent(_) => "stream_event".to_string(),
422 ClaudeOutput::ToolProgress(_) => "tool_progress".to_string(),
423 ClaudeOutput::CommandLifecycle(_) => "command_lifecycle".to_string(),
424 ClaudeOutput::AuthStatus(_) => "auth_status".to_string(),
425 ClaudeOutput::ToolUseSummary(_) => "tool_use_summary".to_string(),
426 ClaudeOutput::PromptSuggestion(_) => "prompt_suggestion".to_string(),
427 ClaudeOutput::ConversationReset(_) => "conversation_reset".to_string(),
428 ClaudeOutput::Progress(_) => "progress".to_string(),
429 ClaudeOutput::QueueOperation(_) => "queue-operation".to_string(),
430 ClaudeOutput::PrLink(_) => "pr-link".to_string(),
431 ClaudeOutput::FileHistorySnapshot(_) => "file-history-snapshot".to_string(),
432 ClaudeOutput::Summary(_) => "summary".to_string(),
433 ClaudeOutput::Mode(_) => "mode".to_string(),
434 ClaudeOutput::PermissionMode(_) => "permission-mode".to_string(),
435 ClaudeOutput::Attachment(_) => "attachment".to_string(),
436 ClaudeOutput::AiTitle(_) => "ai-title".to_string(),
437 ClaudeOutput::LastPrompt(_) => "last-prompt".to_string(),
438 ClaudeOutput::Started(_) => "started".to_string(),
439 }
440 }
441
442 pub fn is_control_request(&self) -> bool {
444 matches!(self, ClaudeOutput::ControlRequest(_))
445 }
446
447 pub fn is_control_response(&self) -> bool {
449 matches!(self, ClaudeOutput::ControlResponse(_))
450 }
451
452 pub fn is_api_error(&self) -> bool {
454 matches!(self, ClaudeOutput::Error(_))
455 }
456
457 pub fn as_control_request(&self) -> Option<&ControlRequest> {
459 match self {
460 ClaudeOutput::ControlRequest(req) => Some(req),
461 _ => None,
462 }
463 }
464
465 pub fn as_anthropic_error(&self) -> Option<&AnthropicError> {
481 match self {
482 ClaudeOutput::Error(err) => Some(err),
483 _ => None,
484 }
485 }
486
487 pub fn is_rate_limit_event(&self) -> bool {
489 matches!(self, ClaudeOutput::RateLimitEvent(_))
490 }
491
492 pub fn as_rate_limit_event(&self) -> Option<&RateLimitEvent> {
494 match self {
495 ClaudeOutput::RateLimitEvent(evt) => Some(evt),
496 _ => None,
497 }
498 }
499
500 pub fn is_error(&self) -> bool {
502 matches!(self, ClaudeOutput::Result(r) if r.is_error)
503 }
504
505 pub fn is_assistant_message(&self) -> bool {
507 matches!(self, ClaudeOutput::Assistant(_))
508 }
509
510 pub fn is_system_message(&self) -> bool {
512 matches!(self, ClaudeOutput::System(_))
513 }
514
515 pub fn is_system_init(&self) -> bool {
526 matches!(self, ClaudeOutput::System(sys) if sys.is_init())
527 }
528
529 pub fn session_id(&self) -> Option<&str> {
545 match self {
546 ClaudeOutput::System(sys) => sys
547 .data
548 .get("session_id")
549 .or_else(|| sys.data.get("sessionId"))
550 .and_then(|v| v.as_str()),
551 ClaudeOutput::Assistant(ass) => Some(&ass.session_id),
552 ClaudeOutput::Result(res) => Some(&res.session_id),
553 ClaudeOutput::TranscriptResult(msg) => msg
554 .data
555 .get("session_id")
556 .or_else(|| msg.data.get("sessionId"))
557 .and_then(|v| v.as_str()),
558 ClaudeOutput::User(_) => None,
559 ClaudeOutput::ControlRequest(_) => None,
560 ClaudeOutput::ControlResponse(_) => None,
561 ClaudeOutput::Error(_) => None,
562 ClaudeOutput::RateLimitEvent(evt) => Some(&evt.session_id),
563 ClaudeOutput::StreamEvent(msg) => Some(&msg.session_id),
564 ClaudeOutput::ToolProgress(msg) => Some(&msg.session_id),
565 ClaudeOutput::CommandLifecycle(msg) => Some(&msg.session_id),
566 ClaudeOutput::AuthStatus(msg) => Some(&msg.session_id),
567 ClaudeOutput::ToolUseSummary(msg) => Some(&msg.session_id),
568 ClaudeOutput::PromptSuggestion(msg) => Some(&msg.session_id),
569 ClaudeOutput::ConversationReset(msg) => Some(&msg.session_id),
570 ClaudeOutput::Progress(msg)
571 | ClaudeOutput::QueueOperation(msg)
572 | ClaudeOutput::PrLink(msg)
573 | ClaudeOutput::FileHistorySnapshot(msg)
574 | ClaudeOutput::Summary(msg)
575 | ClaudeOutput::Mode(msg)
576 | ClaudeOutput::PermissionMode(msg)
577 | ClaudeOutput::Attachment(msg)
578 | ClaudeOutput::AiTitle(msg)
579 | ClaudeOutput::LastPrompt(msg)
580 | ClaudeOutput::Started(msg) => msg
581 .data
582 .get("session_id")
583 .or_else(|| msg.data.get("sessionId"))
584 .and_then(|v| v.as_str()),
585 }
586 }
587
588 pub fn as_tool_use(&self, tool_name: &str) -> Option<&ToolUseBlock> {
607 match self {
608 ClaudeOutput::Assistant(ass) => {
609 ass.message.content.iter().find_map(|block| match block {
610 ContentBlock::ToolUse(tu) if tu.name == tool_name => Some(tu),
611 _ => None,
612 })
613 }
614 _ => None,
615 }
616 }
617
618 pub fn tool_uses(&self) -> impl Iterator<Item = &ToolUseBlock> {
638 let content = match self {
639 ClaudeOutput::Assistant(ass) => Some(&ass.message.content),
640 _ => None,
641 };
642
643 content
644 .into_iter()
645 .flat_map(|c| c.iter())
646 .filter_map(|block| match block {
647 ContentBlock::ToolUse(tu) => Some(tu),
648 _ => None,
649 })
650 }
651
652 pub fn text_content(&self) -> Option<String> {
668 match self {
669 ClaudeOutput::Assistant(ass) => {
670 let texts: Vec<&str> = ass
671 .message
672 .content
673 .iter()
674 .filter_map(|block| match block {
675 ContentBlock::Text(t) => Some(t.text.as_str()),
676 _ => None,
677 })
678 .collect();
679
680 if texts.is_empty() {
681 None
682 } else {
683 Some(texts.join(""))
684 }
685 }
686 _ => None,
687 }
688 }
689
690 pub fn as_assistant(&self) -> Option<&AssistantMessage> {
705 match self {
706 ClaudeOutput::Assistant(ass) => Some(ass),
707 _ => None,
708 }
709 }
710
711 pub fn as_result(&self) -> Option<&ResultMessage> {
727 match self {
728 ClaudeOutput::Result(res) => Some(res),
729 _ => None,
730 }
731 }
732
733 pub fn as_system(&self) -> Option<&SystemMessage> {
735 match self {
736 ClaudeOutput::System(sys) => Some(sys),
737 _ => None,
738 }
739 }
740
741 pub fn parse_json_tolerant(s: &str) -> Result<ClaudeOutput, ParseError> {
746 match Self::parse_json(s) {
748 Ok(output) => Ok(output),
749 Err(first_error) => {
750 if let Some(json_start) = s.find('{') {
752 let trimmed = &s[json_start..];
753 match Self::parse_json(trimmed) {
754 Ok(output) => Ok(output),
755 Err(_) => {
756 Err(first_error)
758 }
759 }
760 } else {
761 Err(first_error)
762 }
763 }
764 }
765 }
766
767 pub fn parse_json(s: &str) -> Result<ClaudeOutput, ParseError> {
769 let value: Value = serde_json::from_str(s).map_err(|e| ParseError {
771 raw_line: s.to_string(),
772 raw_json: None,
773 error_message: format!("Invalid JSON: {}", e),
774 })?;
775
776 serde_json::from_value::<ClaudeOutput>(value.clone()).map_err(|e| ParseError {
778 raw_line: s.to_string(),
779 raw_json: Some(value),
780 error_message: e.to_string(),
781 })
782 }
783}
784
785impl<'de> Deserialize<'de> for ClaudeOutput {
786 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
787 let value = Value::deserialize(deserializer)?;
788 let message_type = value
789 .get("type")
790 .and_then(|v| v.as_str())
791 .ok_or_else(|| serde::de::Error::missing_field("type"))?;
792 let mut payload = value.clone();
793 if let Some(obj) = payload.as_object_mut() {
794 obj.remove("type");
795 }
796
797 fn parse<T, E>(value: Value) -> Result<T, E>
798 where
799 T: serde::de::DeserializeOwned,
800 E: serde::de::Error,
801 {
802 serde_json::from_value(value).map_err(E::custom)
803 }
804
805 match message_type {
806 "system" => parse(payload).map(Self::System),
807 "user" => parse(payload).map(Self::User),
808 "assistant" => parse(payload).map(Self::Assistant),
809 "result" if value.get("subtype").is_some() => parse(payload).map(Self::Result),
810 "result" => parse(payload).map(Self::TranscriptResult),
811 "control_request" => parse(payload).map(Self::ControlRequest),
812 "control_response" => parse(payload).map(Self::ControlResponse),
813 "error" => parse(payload).map(Self::Error),
814 "rate_limit_event" => parse(payload).map(Self::RateLimitEvent),
815 "stream_event" => parse(payload).map(Self::StreamEvent),
816 "tool_progress" => parse(payload).map(Self::ToolProgress),
817 "command_lifecycle" => parse(payload).map(Self::CommandLifecycle),
818 "auth_status" => parse(payload).map(Self::AuthStatus),
819 "tool_use_summary" => parse(payload).map(Self::ToolUseSummary),
820 "prompt_suggestion" => parse(payload).map(Self::PromptSuggestion),
821 "conversation_reset" => parse(payload).map(Self::ConversationReset),
822 "progress" => parse(payload).map(Self::Progress),
823 "queue-operation" => parse(payload).map(Self::QueueOperation),
824 "pr-link" => parse(payload).map(Self::PrLink),
825 "file-history-snapshot" => parse(payload).map(Self::FileHistorySnapshot),
826 "summary" => parse(payload).map(Self::Summary),
827 "mode" => parse(payload).map(Self::Mode),
828 "permission-mode" => parse(payload).map(Self::PermissionMode),
829 "attachment" => parse(payload).map(Self::Attachment),
830 "ai-title" => parse(payload).map(Self::AiTitle),
831 "last-prompt" => parse(payload).map(Self::LastPrompt),
832 "started" => parse(payload).map(Self::Started),
833 other => Err(serde::de::Error::unknown_variant(
834 other,
835 &[
836 "system",
837 "user",
838 "assistant",
839 "result",
840 "control_request",
841 "control_response",
842 "error",
843 "rate_limit_event",
844 "stream_event",
845 "tool_progress",
846 "command_lifecycle",
847 "auth_status",
848 "tool_use_summary",
849 "prompt_suggestion",
850 "conversation_reset",
851 "progress",
852 "queue-operation",
853 "pr-link",
854 "file-history-snapshot",
855 "summary",
856 "mode",
857 "permission-mode",
858 "attachment",
859 "ai-title",
860 "last-prompt",
861 "started",
862 ],
863 )),
864 }
865 }
866}
867
868#[cfg(test)]
869mod tests {
870 use super::*;
871
872 #[test]
873 fn test_deserialize_assistant_message() {
874 let json = r#"{
875 "type": "assistant",
876 "message": {
877 "id": "msg_123",
878 "role": "assistant",
879 "model": "claude-3-sonnet",
880 "content": [{"type": "text", "text": "Hello! How can I help you?"}]
881 },
882 "session_id": "123"
883 }"#;
884
885 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
886 assert!(output.is_assistant_message());
887 }
888
889 #[test]
890 fn test_deserialize_new_top_level_message_types() {
891 let cases = [
892 (
893 r#"{"type":"stream_event","event":{"type":"content_block_delta"},"parent_tool_use_id":null,"uuid":"u1","session_id":"s1","ttft_ms":12}"#,
894 "stream_event",
895 ),
896 (
897 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"}"#,
898 "tool_progress",
899 ),
900 (
901 r#"{"type":"auth_status","isAuthenticating":true,"output":["login"],"uuid":"u3","session_id":"s3"}"#,
902 "auth_status",
903 ),
904 (
905 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"}"#,
906 "tool_use_summary",
907 ),
908 (
909 r#"{"type":"prompt_suggestion","suggestion":"Run tests","uuid":"u5","session_id":"s5"}"#,
910 "prompt_suggestion",
911 ),
912 (
913 r#"{"type":"conversation_reset","new_conversation_id":"new-session","uuid":"u6","session_id":"s6"}"#,
914 "conversation_reset",
915 ),
916 (
917 r#"{"type":"command_lifecycle","command_uuid":"cmd-1","state":"queued","uuid":"u7","session_id":"s7"}"#,
918 "command_lifecycle",
919 ),
920 ];
921
922 for (json, message_type) in cases {
923 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
924 assert_eq!(output.message_type(), message_type);
925 assert!(output.session_id().is_some());
926 }
927 }
928
929 #[test]
930 fn test_command_lifecycle_states_roundtrip() {
931 for state in ["queued", "started", "completed", "cancelled", "discarded"] {
932 let json = format!(
933 r#"{{"type":"command_lifecycle","command_uuid":"cmd-1","state":"{}","uuid":"u1","session_id":"s1"}}"#,
934 state
935 );
936 let output: ClaudeOutput = serde_json::from_str(&json).unwrap();
937 let ClaudeOutput::CommandLifecycle(msg) = &output else {
938 panic!("expected CommandLifecycle");
939 };
940 assert_eq!(msg.state.as_str(), state);
941 assert!(!matches!(msg.state, CommandLifecycleState::Unknown(_)));
942 assert_eq!(serde_json::to_string(&output).unwrap(), json);
943 }
944
945 let json = r#"{"type":"command_lifecycle","command_uuid":"cmd-2","state":"parked","uuid":"u2","session_id":"s2"}"#;
947 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
948 let ClaudeOutput::CommandLifecycle(msg) = &output else {
949 panic!("expected CommandLifecycle");
950 };
951 assert_eq!(
952 msg.state,
953 CommandLifecycleState::Unknown("parked".to_string())
954 );
955 assert_eq!(serde_json::to_string(&output).unwrap(), json);
956 }
957
958 #[test]
959 fn test_tool_progress_heartbeat_and_subagent_retry() {
960 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"}}"#;
961 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
962 let ClaudeOutput::ToolProgress(msg) = &output else {
963 panic!("expected ToolProgress");
964 };
965 assert_eq!(msg.heartbeat, Some(true));
966 assert_eq!(msg.subagent_type.as_deref(), Some("Explore"));
967 let retry = msg.subagent_retry.as_ref().unwrap();
968 assert_eq!(retry.attempt, 2);
969 assert_eq!(retry.error_status, Some(529));
970 assert_eq!(retry.error_category, "overloaded");
971
972 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"}}"#;
974 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
975 let ClaudeOutput::ToolProgress(msg) = &output else {
976 panic!("expected ToolProgress");
977 };
978 assert_eq!(msg.subagent_retry.as_ref().unwrap().error_status, None);
979 }
980
981 #[test]
982 fn test_deserialize_transcript_only_message_types() {
983 let cases = [
984 (
985 r#"{"type":"progress","sessionId":"s1","content":"delta"}"#,
986 "progress",
987 Some("s1"),
988 ),
989 (
990 r#"{"type":"queue-operation","sessionId":"s2","operation":"push"}"#,
991 "queue-operation",
992 Some("s2"),
993 ),
994 (
995 r#"{"type":"pr-link","sessionId":"s3","url":"https://example.invalid/pr"}"#,
996 "pr-link",
997 Some("s3"),
998 ),
999 (
1000 r#"{"type":"file-history-snapshot","sessionId":"s4","files":[]}"#,
1001 "file-history-snapshot",
1002 Some("s4"),
1003 ),
1004 (r#"{"type":"summary","summary":"Done"}"#, "summary", None),
1005 (
1006 r#"{"type":"mode","mode":"normal","sessionId":"s5"}"#,
1007 "mode",
1008 Some("s5"),
1009 ),
1010 (
1011 r#"{"type":"permission-mode","permissionMode":"default","sessionId":"s6"}"#,
1012 "permission-mode",
1013 Some("s6"),
1014 ),
1015 (
1016 r#"{"type":"attachment","attachment":{"type":"task_reminder"},"sessionId":"s7"}"#,
1017 "attachment",
1018 Some("s7"),
1019 ),
1020 (
1021 r#"{"type":"ai-title","aiTitle":"Title","sessionId":"s8"}"#,
1022 "ai-title",
1023 Some("s8"),
1024 ),
1025 (
1026 r#"{"type":"last-prompt","lastPrompt":"prompt","sessionId":"s9"}"#,
1027 "last-prompt",
1028 Some("s9"),
1029 ),
1030 (
1031 r#"{"type":"started","sessionId":"s10"}"#,
1032 "started",
1033 Some("s10"),
1034 ),
1035 (
1036 r#"{"type":"result","key":"k","agentId":"a","result":{"ok":true}}"#,
1037 "result",
1038 None,
1039 ),
1040 ];
1041
1042 for (json, message_type, session_id) in cases {
1043 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1044 assert_eq!(output.message_type(), message_type);
1045 assert_eq!(output.session_id(), session_id);
1046 }
1047 }
1048
1049 #[test]
1050 fn test_transcript_assistant_accepts_camel_case_session_id() {
1051 let json = r#"{
1052 "type": "assistant",
1053 "sessionId": "camel-session",
1054 "message": {
1055 "id": "msg_123",
1056 "role": "assistant",
1057 "model": "claude-3-sonnet",
1058 "content": [{"type": "text", "text": "Hello"}]
1059 }
1060 }"#;
1061
1062 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1063 assert_eq!(output.session_id(), Some("camel-session"));
1064 }
1065
1066 #[test]
1067 fn test_is_system_init() {
1068 let init_json = r#"{
1069 "type": "system",
1070 "subtype": "init",
1071 "session_id": "test-session"
1072 }"#;
1073 let output: ClaudeOutput = serde_json::from_str(init_json).unwrap();
1074 assert!(output.is_system_init());
1075
1076 let status_json = r#"{
1077 "type": "system",
1078 "subtype": "status",
1079 "session_id": "test-session"
1080 }"#;
1081 let output: ClaudeOutput = serde_json::from_str(status_json).unwrap();
1082 assert!(!output.is_system_init());
1083 }
1084
1085 #[test]
1086 fn test_session_id() {
1087 let result_json = r#"{
1089 "type": "result",
1090 "subtype": "success",
1091 "is_error": false,
1092 "duration_ms": 100,
1093 "duration_api_ms": 200,
1094 "num_turns": 1,
1095 "session_id": "result-session",
1096 "total_cost_usd": 0.01
1097 }"#;
1098 let output: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1099 assert_eq!(output.session_id(), Some("result-session"));
1100
1101 let assistant_json = r#"{
1103 "type": "assistant",
1104 "message": {
1105 "id": "msg_1",
1106 "role": "assistant",
1107 "model": "claude-3",
1108 "content": []
1109 },
1110 "session_id": "assistant-session"
1111 }"#;
1112 let output: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
1113 assert_eq!(output.session_id(), Some("assistant-session"));
1114
1115 let system_json = r#"{
1117 "type": "system",
1118 "subtype": "init",
1119 "session_id": "system-session"
1120 }"#;
1121 let output: ClaudeOutput = serde_json::from_str(system_json).unwrap();
1122 assert_eq!(output.session_id(), Some("system-session"));
1123 }
1124
1125 #[test]
1126 fn test_as_tool_use() {
1127 let json = r#"{
1128 "type": "assistant",
1129 "message": {
1130 "id": "msg_1",
1131 "role": "assistant",
1132 "model": "claude-3",
1133 "content": [
1134 {"type": "text", "text": "Let me run that command."},
1135 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls -la"}},
1136 {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/test"}}
1137 ]
1138 },
1139 "session_id": "abc"
1140 }"#;
1141 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1142
1143 let bash = output.as_tool_use("Bash");
1145 assert!(bash.is_some());
1146 assert_eq!(bash.unwrap().id, "tu_1");
1147
1148 let read = output.as_tool_use("Read");
1150 assert!(read.is_some());
1151 assert_eq!(read.unwrap().id, "tu_2");
1152
1153 assert!(output.as_tool_use("Write").is_none());
1155
1156 let result_json = r#"{
1158 "type": "result",
1159 "subtype": "success",
1160 "is_error": false,
1161 "duration_ms": 100,
1162 "duration_api_ms": 200,
1163 "num_turns": 1,
1164 "session_id": "abc",
1165 "total_cost_usd": 0.01
1166 }"#;
1167 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1168 assert!(result.as_tool_use("Bash").is_none());
1169 }
1170
1171 #[test]
1172 fn test_tool_uses() {
1173 let json = r#"{
1174 "type": "assistant",
1175 "message": {
1176 "id": "msg_1",
1177 "role": "assistant",
1178 "model": "claude-3",
1179 "content": [
1180 {"type": "text", "text": "Running commands..."},
1181 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls"}},
1182 {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/a"}},
1183 {"type": "tool_use", "id": "tu_3", "name": "Write", "input": {"file_path": "/tmp/b", "content": "x"}}
1184 ]
1185 },
1186 "session_id": "abc"
1187 }"#;
1188 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1189
1190 let tools: Vec<_> = output.tool_uses().collect();
1191 assert_eq!(tools.len(), 3);
1192 assert_eq!(tools[0].name, "Bash");
1193 assert_eq!(tools[1].name, "Read");
1194 assert_eq!(tools[2].name, "Write");
1195 }
1196
1197 #[test]
1198 fn test_text_content() {
1199 let json = r#"{
1201 "type": "assistant",
1202 "message": {
1203 "id": "msg_1",
1204 "role": "assistant",
1205 "model": "claude-3",
1206 "content": [{"type": "text", "text": "Hello, world!"}]
1207 },
1208 "session_id": "abc"
1209 }"#;
1210 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1211 assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
1212
1213 let json = r#"{
1215 "type": "assistant",
1216 "message": {
1217 "id": "msg_1",
1218 "role": "assistant",
1219 "model": "claude-3",
1220 "content": [
1221 {"type": "text", "text": "Hello, "},
1222 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}},
1223 {"type": "text", "text": "world!"}
1224 ]
1225 },
1226 "session_id": "abc"
1227 }"#;
1228 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1229 assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
1230
1231 let json = r#"{
1233 "type": "assistant",
1234 "message": {
1235 "id": "msg_1",
1236 "role": "assistant",
1237 "model": "claude-3",
1238 "content": [{"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}}]
1239 },
1240 "session_id": "abc"
1241 }"#;
1242 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1243 assert_eq!(output.text_content(), None);
1244
1245 let json = r#"{
1247 "type": "result",
1248 "subtype": "success",
1249 "is_error": false,
1250 "duration_ms": 100,
1251 "duration_api_ms": 200,
1252 "num_turns": 1,
1253 "session_id": "abc",
1254 "total_cost_usd": 0.01
1255 }"#;
1256 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1257 assert_eq!(output.text_content(), None);
1258 }
1259
1260 #[test]
1261 fn test_as_assistant() {
1262 let json = r#"{
1263 "type": "assistant",
1264 "message": {
1265 "id": "msg_1",
1266 "role": "assistant",
1267 "model": "claude-sonnet-4",
1268 "content": []
1269 },
1270 "session_id": "abc"
1271 }"#;
1272 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1273
1274 let assistant = output.as_assistant();
1275 assert!(assistant.is_some());
1276 assert_eq!(assistant.unwrap().message.model, "claude-sonnet-4");
1277
1278 let result_json = r#"{
1280 "type": "result",
1281 "subtype": "success",
1282 "is_error": false,
1283 "duration_ms": 100,
1284 "duration_api_ms": 200,
1285 "num_turns": 1,
1286 "session_id": "abc",
1287 "total_cost_usd": 0.01
1288 }"#;
1289 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1290 assert!(result.as_assistant().is_none());
1291 }
1292
1293 #[test]
1294 fn test_as_result() {
1295 let json = r#"{
1296 "type": "result",
1297 "subtype": "success",
1298 "is_error": false,
1299 "duration_ms": 100,
1300 "duration_api_ms": 200,
1301 "num_turns": 5,
1302 "session_id": "abc",
1303 "total_cost_usd": 0.05
1304 }"#;
1305 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1306
1307 let result = output.as_result();
1308 assert!(result.is_some());
1309 assert_eq!(result.unwrap().num_turns, 5);
1310 assert_eq!(result.unwrap().total_cost_usd, 0.05);
1311
1312 let assistant_json = r#"{
1314 "type": "assistant",
1315 "message": {
1316 "id": "msg_1",
1317 "role": "assistant",
1318 "model": "claude-3",
1319 "content": []
1320 },
1321 "session_id": "abc"
1322 }"#;
1323 let assistant: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
1324 assert!(assistant.as_result().is_none());
1325 }
1326
1327 #[test]
1328 fn test_as_system() {
1329 let json = r#"{
1330 "type": "system",
1331 "subtype": "init",
1332 "session_id": "abc",
1333 "model": "claude-3"
1334 }"#;
1335 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1336
1337 let system = output.as_system();
1338 assert!(system.is_some());
1339 assert!(system.unwrap().is_init());
1340
1341 let result_json = r#"{
1343 "type": "result",
1344 "subtype": "success",
1345 "is_error": false,
1346 "duration_ms": 100,
1347 "duration_api_ms": 200,
1348 "num_turns": 1,
1349 "session_id": "abc",
1350 "total_cost_usd": 0.01
1351 }"#;
1352 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1353 assert!(result.as_system().is_none());
1354 }
1355}