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