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}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct ToolProgressMessage {
138 pub tool_use_id: String,
139 pub tool_name: String,
140 pub parent_tool_use_id: Option<String>,
141 pub elapsed_time_seconds: f64,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub task_id: Option<String>,
144 pub uuid: String,
145 pub session_id: String,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub heartbeat: Option<bool>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub subagent_type: Option<String>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub subagent_retry: Option<SubagentRetry>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct SubagentRetry {
162 pub agent_id: String,
163 pub attempt: u64,
164 pub max_retries: u64,
165 pub retry_delay_ms: u64,
166 pub error_status: Option<u16>,
167 pub error_category: String,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct CommandLifecycleMessage {
179 pub command_uuid: String,
182 pub state: CommandLifecycleState,
183 pub uuid: String,
184 pub session_id: String,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Hash)]
189pub enum CommandLifecycleState {
190 Queued,
192 Started,
194 Completed,
196 Cancelled,
199 Discarded,
201 Unknown(String),
203}
204
205impl CommandLifecycleState {
206 pub fn as_str(&self) -> &str {
207 match self {
208 Self::Queued => "queued",
209 Self::Started => "started",
210 Self::Completed => "completed",
211 Self::Cancelled => "cancelled",
212 Self::Discarded => "discarded",
213 Self::Unknown(s) => s.as_str(),
214 }
215 }
216}
217
218impl std::fmt::Display for CommandLifecycleState {
219 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220 f.write_str(self.as_str())
221 }
222}
223
224impl From<&str> for CommandLifecycleState {
225 fn from(s: &str) -> Self {
226 match s {
227 "queued" => Self::Queued,
228 "started" => Self::Started,
229 "completed" => Self::Completed,
230 "cancelled" => Self::Cancelled,
231 "discarded" => Self::Discarded,
232 other => Self::Unknown(other.to_string()),
233 }
234 }
235}
236
237impl Serialize for CommandLifecycleState {
238 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
239 serializer.serialize_str(self.as_str())
240 }
241}
242
243impl<'de> Deserialize<'de> for CommandLifecycleState {
244 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
245 let s = String::deserialize(deserializer)?;
246 Ok(Self::from(s.as_str()))
247 }
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct AuthStatusMessage {
252 #[serde(rename = "isAuthenticating")]
253 pub is_authenticating: bool,
254 pub output: Vec<String>,
255 #[serde(skip_serializing_if = "Option::is_none")]
256 pub error: Option<String>,
257 pub uuid: String,
258 pub session_id: String,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct ToolUseSummaryMessage {
263 pub summary: String,
264 pub preceding_tool_use_ids: Vec<String>,
265 pub uuid: String,
266 pub session_id: String,
267 #[serde(skip_serializing_if = "Option::is_none")]
268 pub timestamp: Option<String>,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct PromptSuggestionMessage {
273 pub suggestion: String,
274 pub uuid: String,
275 pub session_id: String,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct ConversationResetMessage {
297 pub new_conversation_id: String,
301 pub uuid: String,
302 pub session_id: String,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub trigger: Option<ConversationResetTrigger>,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub user_message_uuid: Option<String>,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub timestamp: Option<String>,
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, Hash)]
329pub enum ConversationResetTrigger {
330 Clear,
332 PlanModeExit,
334 FreshSession,
336 Onboarding,
338 Unknown(String),
340}
341
342impl ConversationResetTrigger {
343 pub fn as_str(&self) -> &str {
344 match self {
345 Self::Clear => "clear",
346 Self::PlanModeExit => "plan_mode_exit",
347 Self::FreshSession => "fresh_session",
348 Self::Onboarding => "onboarding",
349 Self::Unknown(s) => s.as_str(),
350 }
351 }
352}
353
354impl std::fmt::Display for ConversationResetTrigger {
355 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
356 f.write_str(self.as_str())
357 }
358}
359
360impl From<&str> for ConversationResetTrigger {
361 fn from(s: &str) -> Self {
362 match s {
363 "clear" => Self::Clear,
364 "plan_mode_exit" => Self::PlanModeExit,
365 "fresh_session" => Self::FreshSession,
366 "onboarding" => Self::Onboarding,
367 other => Self::Unknown(other.to_string()),
368 }
369 }
370}
371
372impl Serialize for ConversationResetTrigger {
373 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
374 serializer.serialize_str(self.as_str())
375 }
376}
377
378impl<'de> Deserialize<'de> for ConversationResetTrigger {
379 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
380 let s = String::deserialize(deserializer)?;
381 Ok(Self::from(s.as_str()))
382 }
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize)]
392pub struct TranscriptMessage {
393 #[serde(flatten)]
394 pub data: Map<String, Value>,
395}
396
397impl ClaudeOutput {
398 pub fn message_type(&self) -> String {
400 match self {
401 ClaudeOutput::System(_) => "system".to_string(),
402 ClaudeOutput::User(_) => "user".to_string(),
403 ClaudeOutput::Assistant(_) => "assistant".to_string(),
404 ClaudeOutput::Result(_) => "result".to_string(),
405 ClaudeOutput::TranscriptResult(_) => "result".to_string(),
406 ClaudeOutput::ControlRequest(_) => "control_request".to_string(),
407 ClaudeOutput::ControlResponse(_) => "control_response".to_string(),
408 ClaudeOutput::Error(_) => "error".to_string(),
409 ClaudeOutput::RateLimitEvent(_) => "rate_limit_event".to_string(),
410 ClaudeOutput::StreamEvent(_) => "stream_event".to_string(),
411 ClaudeOutput::ToolProgress(_) => "tool_progress".to_string(),
412 ClaudeOutput::CommandLifecycle(_) => "command_lifecycle".to_string(),
413 ClaudeOutput::AuthStatus(_) => "auth_status".to_string(),
414 ClaudeOutput::ToolUseSummary(_) => "tool_use_summary".to_string(),
415 ClaudeOutput::PromptSuggestion(_) => "prompt_suggestion".to_string(),
416 ClaudeOutput::ConversationReset(_) => "conversation_reset".to_string(),
417 ClaudeOutput::Progress(_) => "progress".to_string(),
418 ClaudeOutput::QueueOperation(_) => "queue-operation".to_string(),
419 ClaudeOutput::PrLink(_) => "pr-link".to_string(),
420 ClaudeOutput::FileHistorySnapshot(_) => "file-history-snapshot".to_string(),
421 ClaudeOutput::Summary(_) => "summary".to_string(),
422 ClaudeOutput::Mode(_) => "mode".to_string(),
423 ClaudeOutput::PermissionMode(_) => "permission-mode".to_string(),
424 ClaudeOutput::Attachment(_) => "attachment".to_string(),
425 ClaudeOutput::AiTitle(_) => "ai-title".to_string(),
426 ClaudeOutput::LastPrompt(_) => "last-prompt".to_string(),
427 ClaudeOutput::Started(_) => "started".to_string(),
428 }
429 }
430
431 pub fn is_control_request(&self) -> bool {
433 matches!(self, ClaudeOutput::ControlRequest(_))
434 }
435
436 pub fn is_control_response(&self) -> bool {
438 matches!(self, ClaudeOutput::ControlResponse(_))
439 }
440
441 pub fn is_api_error(&self) -> bool {
443 matches!(self, ClaudeOutput::Error(_))
444 }
445
446 pub fn as_control_request(&self) -> Option<&ControlRequest> {
448 match self {
449 ClaudeOutput::ControlRequest(req) => Some(req),
450 _ => None,
451 }
452 }
453
454 pub fn as_anthropic_error(&self) -> Option<&AnthropicError> {
470 match self {
471 ClaudeOutput::Error(err) => Some(err),
472 _ => None,
473 }
474 }
475
476 pub fn is_rate_limit_event(&self) -> bool {
478 matches!(self, ClaudeOutput::RateLimitEvent(_))
479 }
480
481 pub fn as_rate_limit_event(&self) -> Option<&RateLimitEvent> {
483 match self {
484 ClaudeOutput::RateLimitEvent(evt) => Some(evt),
485 _ => None,
486 }
487 }
488
489 pub fn is_error(&self) -> bool {
491 matches!(self, ClaudeOutput::Result(r) if r.is_error)
492 }
493
494 pub fn is_assistant_message(&self) -> bool {
496 matches!(self, ClaudeOutput::Assistant(_))
497 }
498
499 pub fn is_system_message(&self) -> bool {
501 matches!(self, ClaudeOutput::System(_))
502 }
503
504 pub fn is_system_init(&self) -> bool {
515 matches!(self, ClaudeOutput::System(sys) if sys.is_init())
516 }
517
518 pub fn session_id(&self) -> Option<&str> {
534 match self {
535 ClaudeOutput::System(sys) => sys
536 .data
537 .get("session_id")
538 .or_else(|| sys.data.get("sessionId"))
539 .and_then(|v| v.as_str()),
540 ClaudeOutput::Assistant(ass) => Some(&ass.session_id),
541 ClaudeOutput::Result(res) => Some(&res.session_id),
542 ClaudeOutput::TranscriptResult(msg) => msg
543 .data
544 .get("session_id")
545 .or_else(|| msg.data.get("sessionId"))
546 .and_then(|v| v.as_str()),
547 ClaudeOutput::User(_) => None,
548 ClaudeOutput::ControlRequest(_) => None,
549 ClaudeOutput::ControlResponse(_) => None,
550 ClaudeOutput::Error(_) => None,
551 ClaudeOutput::RateLimitEvent(evt) => Some(&evt.session_id),
552 ClaudeOutput::StreamEvent(msg) => Some(&msg.session_id),
553 ClaudeOutput::ToolProgress(msg) => Some(&msg.session_id),
554 ClaudeOutput::CommandLifecycle(msg) => Some(&msg.session_id),
555 ClaudeOutput::AuthStatus(msg) => Some(&msg.session_id),
556 ClaudeOutput::ToolUseSummary(msg) => Some(&msg.session_id),
557 ClaudeOutput::PromptSuggestion(msg) => Some(&msg.session_id),
558 ClaudeOutput::ConversationReset(msg) => Some(&msg.session_id),
559 ClaudeOutput::Progress(msg)
560 | ClaudeOutput::QueueOperation(msg)
561 | ClaudeOutput::PrLink(msg)
562 | ClaudeOutput::FileHistorySnapshot(msg)
563 | ClaudeOutput::Summary(msg)
564 | ClaudeOutput::Mode(msg)
565 | ClaudeOutput::PermissionMode(msg)
566 | ClaudeOutput::Attachment(msg)
567 | ClaudeOutput::AiTitle(msg)
568 | ClaudeOutput::LastPrompt(msg)
569 | ClaudeOutput::Started(msg) => msg
570 .data
571 .get("session_id")
572 .or_else(|| msg.data.get("sessionId"))
573 .and_then(|v| v.as_str()),
574 }
575 }
576
577 pub fn as_tool_use(&self, tool_name: &str) -> Option<&ToolUseBlock> {
596 match self {
597 ClaudeOutput::Assistant(ass) => {
598 ass.message.content.iter().find_map(|block| match block {
599 ContentBlock::ToolUse(tu) if tu.name == tool_name => Some(tu),
600 _ => None,
601 })
602 }
603 _ => None,
604 }
605 }
606
607 pub fn tool_uses(&self) -> impl Iterator<Item = &ToolUseBlock> {
627 let content = match self {
628 ClaudeOutput::Assistant(ass) => Some(&ass.message.content),
629 _ => None,
630 };
631
632 content
633 .into_iter()
634 .flat_map(|c| c.iter())
635 .filter_map(|block| match block {
636 ContentBlock::ToolUse(tu) => Some(tu),
637 _ => None,
638 })
639 }
640
641 pub fn text_content(&self) -> Option<String> {
657 match self {
658 ClaudeOutput::Assistant(ass) => {
659 let texts: Vec<&str> = ass
660 .message
661 .content
662 .iter()
663 .filter_map(|block| match block {
664 ContentBlock::Text(t) => Some(t.text.as_str()),
665 _ => None,
666 })
667 .collect();
668
669 if texts.is_empty() {
670 None
671 } else {
672 Some(texts.join(""))
673 }
674 }
675 _ => None,
676 }
677 }
678
679 pub fn as_assistant(&self) -> Option<&AssistantMessage> {
694 match self {
695 ClaudeOutput::Assistant(ass) => Some(ass),
696 _ => None,
697 }
698 }
699
700 pub fn as_result(&self) -> Option<&ResultMessage> {
716 match self {
717 ClaudeOutput::Result(res) => Some(res),
718 _ => None,
719 }
720 }
721
722 pub fn as_system(&self) -> Option<&SystemMessage> {
724 match self {
725 ClaudeOutput::System(sys) => Some(sys),
726 _ => None,
727 }
728 }
729
730 pub fn parse_json_tolerant(s: &str) -> Result<ClaudeOutput, ParseError> {
735 match Self::parse_json(s) {
737 Ok(output) => Ok(output),
738 Err(first_error) => {
739 if let Some(json_start) = s.find('{') {
741 let trimmed = &s[json_start..];
742 match Self::parse_json(trimmed) {
743 Ok(output) => Ok(output),
744 Err(_) => {
745 Err(first_error)
747 }
748 }
749 } else {
750 Err(first_error)
751 }
752 }
753 }
754 }
755
756 pub fn parse_json(s: &str) -> Result<ClaudeOutput, ParseError> {
758 let value: Value = serde_json::from_str(s).map_err(|e| ParseError {
760 raw_line: s.to_string(),
761 raw_json: None,
762 error_message: format!("Invalid JSON: {}", e),
763 })?;
764
765 serde_json::from_value::<ClaudeOutput>(value.clone()).map_err(|e| ParseError {
767 raw_line: s.to_string(),
768 raw_json: Some(value),
769 error_message: e.to_string(),
770 })
771 }
772}
773
774impl<'de> Deserialize<'de> for ClaudeOutput {
775 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
776 let value = Value::deserialize(deserializer)?;
777 let message_type = value
778 .get("type")
779 .and_then(|v| v.as_str())
780 .ok_or_else(|| serde::de::Error::missing_field("type"))?;
781 let mut payload = value.clone();
782 if let Some(obj) = payload.as_object_mut() {
783 obj.remove("type");
784 }
785
786 fn parse<T, E>(value: Value) -> Result<T, E>
787 where
788 T: serde::de::DeserializeOwned,
789 E: serde::de::Error,
790 {
791 serde_json::from_value(value).map_err(E::custom)
792 }
793
794 match message_type {
795 "system" => parse(payload).map(Self::System),
796 "user" => parse(payload).map(Self::User),
797 "assistant" => parse(payload).map(Self::Assistant),
798 "result" if value.get("subtype").is_some() => parse(payload).map(Self::Result),
799 "result" => parse(payload).map(Self::TranscriptResult),
800 "control_request" => parse(payload).map(Self::ControlRequest),
801 "control_response" => parse(payload).map(Self::ControlResponse),
802 "error" => parse(payload).map(Self::Error),
803 "rate_limit_event" => parse(payload).map(Self::RateLimitEvent),
804 "stream_event" => parse(payload).map(Self::StreamEvent),
805 "tool_progress" => parse(payload).map(Self::ToolProgress),
806 "command_lifecycle" => parse(payload).map(Self::CommandLifecycle),
807 "auth_status" => parse(payload).map(Self::AuthStatus),
808 "tool_use_summary" => parse(payload).map(Self::ToolUseSummary),
809 "prompt_suggestion" => parse(payload).map(Self::PromptSuggestion),
810 "conversation_reset" => parse(payload).map(Self::ConversationReset),
811 "progress" => parse(payload).map(Self::Progress),
812 "queue-operation" => parse(payload).map(Self::QueueOperation),
813 "pr-link" => parse(payload).map(Self::PrLink),
814 "file-history-snapshot" => parse(payload).map(Self::FileHistorySnapshot),
815 "summary" => parse(payload).map(Self::Summary),
816 "mode" => parse(payload).map(Self::Mode),
817 "permission-mode" => parse(payload).map(Self::PermissionMode),
818 "attachment" => parse(payload).map(Self::Attachment),
819 "ai-title" => parse(payload).map(Self::AiTitle),
820 "last-prompt" => parse(payload).map(Self::LastPrompt),
821 "started" => parse(payload).map(Self::Started),
822 other => Err(serde::de::Error::unknown_variant(
823 other,
824 &[
825 "system",
826 "user",
827 "assistant",
828 "result",
829 "control_request",
830 "control_response",
831 "error",
832 "rate_limit_event",
833 "stream_event",
834 "tool_progress",
835 "command_lifecycle",
836 "auth_status",
837 "tool_use_summary",
838 "prompt_suggestion",
839 "conversation_reset",
840 "progress",
841 "queue-operation",
842 "pr-link",
843 "file-history-snapshot",
844 "summary",
845 "mode",
846 "permission-mode",
847 "attachment",
848 "ai-title",
849 "last-prompt",
850 "started",
851 ],
852 )),
853 }
854 }
855}
856
857#[cfg(test)]
858mod tests {
859 use super::*;
860
861 #[test]
862 fn test_deserialize_assistant_message() {
863 let json = r#"{
864 "type": "assistant",
865 "message": {
866 "id": "msg_123",
867 "role": "assistant",
868 "model": "claude-3-sonnet",
869 "content": [{"type": "text", "text": "Hello! How can I help you?"}]
870 },
871 "session_id": "123"
872 }"#;
873
874 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
875 assert!(output.is_assistant_message());
876 }
877
878 #[test]
879 fn test_deserialize_new_top_level_message_types() {
880 let cases = [
881 (
882 r#"{"type":"stream_event","event":{"type":"content_block_delta"},"parent_tool_use_id":null,"uuid":"u1","session_id":"s1","ttft_ms":12}"#,
883 "stream_event",
884 ),
885 (
886 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"}"#,
887 "tool_progress",
888 ),
889 (
890 r#"{"type":"auth_status","isAuthenticating":true,"output":["login"],"uuid":"u3","session_id":"s3"}"#,
891 "auth_status",
892 ),
893 (
894 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"}"#,
895 "tool_use_summary",
896 ),
897 (
898 r#"{"type":"prompt_suggestion","suggestion":"Run tests","uuid":"u5","session_id":"s5"}"#,
899 "prompt_suggestion",
900 ),
901 (
902 r#"{"type":"conversation_reset","new_conversation_id":"new-session","uuid":"u6","session_id":"s6"}"#,
903 "conversation_reset",
904 ),
905 (
906 r#"{"type":"command_lifecycle","command_uuid":"cmd-1","state":"queued","uuid":"u7","session_id":"s7"}"#,
907 "command_lifecycle",
908 ),
909 ];
910
911 for (json, message_type) in cases {
912 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
913 assert_eq!(output.message_type(), message_type);
914 assert!(output.session_id().is_some());
915 }
916 }
917
918 #[test]
919 fn test_command_lifecycle_states_roundtrip() {
920 for state in ["queued", "started", "completed", "cancelled", "discarded"] {
921 let json = format!(
922 r#"{{"type":"command_lifecycle","command_uuid":"cmd-1","state":"{}","uuid":"u1","session_id":"s1"}}"#,
923 state
924 );
925 let output: ClaudeOutput = serde_json::from_str(&json).unwrap();
926 let ClaudeOutput::CommandLifecycle(msg) = &output else {
927 panic!("expected CommandLifecycle");
928 };
929 assert_eq!(msg.state.as_str(), state);
930 assert!(!matches!(msg.state, CommandLifecycleState::Unknown(_)));
931 assert_eq!(serde_json::to_string(&output).unwrap(), json);
932 }
933
934 let json = r#"{"type":"command_lifecycle","command_uuid":"cmd-2","state":"parked","uuid":"u2","session_id":"s2"}"#;
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!(
941 msg.state,
942 CommandLifecycleState::Unknown("parked".to_string())
943 );
944 assert_eq!(serde_json::to_string(&output).unwrap(), json);
945 }
946
947 #[test]
948 fn test_tool_progress_heartbeat_and_subagent_retry() {
949 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"}}"#;
950 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
951 let ClaudeOutput::ToolProgress(msg) = &output else {
952 panic!("expected ToolProgress");
953 };
954 assert_eq!(msg.heartbeat, Some(true));
955 assert_eq!(msg.subagent_type.as_deref(), Some("Explore"));
956 let retry = msg.subagent_retry.as_ref().unwrap();
957 assert_eq!(retry.attempt, 2);
958 assert_eq!(retry.error_status, Some(529));
959 assert_eq!(retry.error_category, "overloaded");
960
961 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"}}"#;
963 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
964 let ClaudeOutput::ToolProgress(msg) = &output else {
965 panic!("expected ToolProgress");
966 };
967 assert_eq!(msg.subagent_retry.as_ref().unwrap().error_status, None);
968 }
969
970 #[test]
971 fn test_deserialize_transcript_only_message_types() {
972 let cases = [
973 (
974 r#"{"type":"progress","sessionId":"s1","content":"delta"}"#,
975 "progress",
976 Some("s1"),
977 ),
978 (
979 r#"{"type":"queue-operation","sessionId":"s2","operation":"push"}"#,
980 "queue-operation",
981 Some("s2"),
982 ),
983 (
984 r#"{"type":"pr-link","sessionId":"s3","url":"https://example.invalid/pr"}"#,
985 "pr-link",
986 Some("s3"),
987 ),
988 (
989 r#"{"type":"file-history-snapshot","sessionId":"s4","files":[]}"#,
990 "file-history-snapshot",
991 Some("s4"),
992 ),
993 (r#"{"type":"summary","summary":"Done"}"#, "summary", None),
994 (
995 r#"{"type":"mode","mode":"normal","sessionId":"s5"}"#,
996 "mode",
997 Some("s5"),
998 ),
999 (
1000 r#"{"type":"permission-mode","permissionMode":"default","sessionId":"s6"}"#,
1001 "permission-mode",
1002 Some("s6"),
1003 ),
1004 (
1005 r#"{"type":"attachment","attachment":{"type":"task_reminder"},"sessionId":"s7"}"#,
1006 "attachment",
1007 Some("s7"),
1008 ),
1009 (
1010 r#"{"type":"ai-title","aiTitle":"Title","sessionId":"s8"}"#,
1011 "ai-title",
1012 Some("s8"),
1013 ),
1014 (
1015 r#"{"type":"last-prompt","lastPrompt":"prompt","sessionId":"s9"}"#,
1016 "last-prompt",
1017 Some("s9"),
1018 ),
1019 (
1020 r#"{"type":"started","sessionId":"s10"}"#,
1021 "started",
1022 Some("s10"),
1023 ),
1024 (
1025 r#"{"type":"result","key":"k","agentId":"a","result":{"ok":true}}"#,
1026 "result",
1027 None,
1028 ),
1029 ];
1030
1031 for (json, message_type, session_id) in cases {
1032 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1033 assert_eq!(output.message_type(), message_type);
1034 assert_eq!(output.session_id(), session_id);
1035 }
1036 }
1037
1038 #[test]
1039 fn test_transcript_assistant_accepts_camel_case_session_id() {
1040 let json = r#"{
1041 "type": "assistant",
1042 "sessionId": "camel-session",
1043 "message": {
1044 "id": "msg_123",
1045 "role": "assistant",
1046 "model": "claude-3-sonnet",
1047 "content": [{"type": "text", "text": "Hello"}]
1048 }
1049 }"#;
1050
1051 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1052 assert_eq!(output.session_id(), Some("camel-session"));
1053 }
1054
1055 #[test]
1056 fn test_is_system_init() {
1057 let init_json = r#"{
1058 "type": "system",
1059 "subtype": "init",
1060 "session_id": "test-session"
1061 }"#;
1062 let output: ClaudeOutput = serde_json::from_str(init_json).unwrap();
1063 assert!(output.is_system_init());
1064
1065 let status_json = r#"{
1066 "type": "system",
1067 "subtype": "status",
1068 "session_id": "test-session"
1069 }"#;
1070 let output: ClaudeOutput = serde_json::from_str(status_json).unwrap();
1071 assert!(!output.is_system_init());
1072 }
1073
1074 #[test]
1075 fn test_session_id() {
1076 let result_json = r#"{
1078 "type": "result",
1079 "subtype": "success",
1080 "is_error": false,
1081 "duration_ms": 100,
1082 "duration_api_ms": 200,
1083 "num_turns": 1,
1084 "session_id": "result-session",
1085 "total_cost_usd": 0.01
1086 }"#;
1087 let output: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1088 assert_eq!(output.session_id(), Some("result-session"));
1089
1090 let assistant_json = r#"{
1092 "type": "assistant",
1093 "message": {
1094 "id": "msg_1",
1095 "role": "assistant",
1096 "model": "claude-3",
1097 "content": []
1098 },
1099 "session_id": "assistant-session"
1100 }"#;
1101 let output: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
1102 assert_eq!(output.session_id(), Some("assistant-session"));
1103
1104 let system_json = r#"{
1106 "type": "system",
1107 "subtype": "init",
1108 "session_id": "system-session"
1109 }"#;
1110 let output: ClaudeOutput = serde_json::from_str(system_json).unwrap();
1111 assert_eq!(output.session_id(), Some("system-session"));
1112 }
1113
1114 #[test]
1115 fn test_as_tool_use() {
1116 let json = r#"{
1117 "type": "assistant",
1118 "message": {
1119 "id": "msg_1",
1120 "role": "assistant",
1121 "model": "claude-3",
1122 "content": [
1123 {"type": "text", "text": "Let me run that command."},
1124 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls -la"}},
1125 {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/test"}}
1126 ]
1127 },
1128 "session_id": "abc"
1129 }"#;
1130 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1131
1132 let bash = output.as_tool_use("Bash");
1134 assert!(bash.is_some());
1135 assert_eq!(bash.unwrap().id, "tu_1");
1136
1137 let read = output.as_tool_use("Read");
1139 assert!(read.is_some());
1140 assert_eq!(read.unwrap().id, "tu_2");
1141
1142 assert!(output.as_tool_use("Write").is_none());
1144
1145 let result_json = r#"{
1147 "type": "result",
1148 "subtype": "success",
1149 "is_error": false,
1150 "duration_ms": 100,
1151 "duration_api_ms": 200,
1152 "num_turns": 1,
1153 "session_id": "abc",
1154 "total_cost_usd": 0.01
1155 }"#;
1156 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1157 assert!(result.as_tool_use("Bash").is_none());
1158 }
1159
1160 #[test]
1161 fn test_tool_uses() {
1162 let json = r#"{
1163 "type": "assistant",
1164 "message": {
1165 "id": "msg_1",
1166 "role": "assistant",
1167 "model": "claude-3",
1168 "content": [
1169 {"type": "text", "text": "Running commands..."},
1170 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls"}},
1171 {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/a"}},
1172 {"type": "tool_use", "id": "tu_3", "name": "Write", "input": {"file_path": "/tmp/b", "content": "x"}}
1173 ]
1174 },
1175 "session_id": "abc"
1176 }"#;
1177 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1178
1179 let tools: Vec<_> = output.tool_uses().collect();
1180 assert_eq!(tools.len(), 3);
1181 assert_eq!(tools[0].name, "Bash");
1182 assert_eq!(tools[1].name, "Read");
1183 assert_eq!(tools[2].name, "Write");
1184 }
1185
1186 #[test]
1187 fn test_text_content() {
1188 let json = r#"{
1190 "type": "assistant",
1191 "message": {
1192 "id": "msg_1",
1193 "role": "assistant",
1194 "model": "claude-3",
1195 "content": [{"type": "text", "text": "Hello, world!"}]
1196 },
1197 "session_id": "abc"
1198 }"#;
1199 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1200 assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
1201
1202 let json = r#"{
1204 "type": "assistant",
1205 "message": {
1206 "id": "msg_1",
1207 "role": "assistant",
1208 "model": "claude-3",
1209 "content": [
1210 {"type": "text", "text": "Hello, "},
1211 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}},
1212 {"type": "text", "text": "world!"}
1213 ]
1214 },
1215 "session_id": "abc"
1216 }"#;
1217 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1218 assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
1219
1220 let json = r#"{
1222 "type": "assistant",
1223 "message": {
1224 "id": "msg_1",
1225 "role": "assistant",
1226 "model": "claude-3",
1227 "content": [{"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}}]
1228 },
1229 "session_id": "abc"
1230 }"#;
1231 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1232 assert_eq!(output.text_content(), None);
1233
1234 let json = r#"{
1236 "type": "result",
1237 "subtype": "success",
1238 "is_error": false,
1239 "duration_ms": 100,
1240 "duration_api_ms": 200,
1241 "num_turns": 1,
1242 "session_id": "abc",
1243 "total_cost_usd": 0.01
1244 }"#;
1245 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1246 assert_eq!(output.text_content(), None);
1247 }
1248
1249 #[test]
1250 fn test_as_assistant() {
1251 let json = r#"{
1252 "type": "assistant",
1253 "message": {
1254 "id": "msg_1",
1255 "role": "assistant",
1256 "model": "claude-sonnet-4",
1257 "content": []
1258 },
1259 "session_id": "abc"
1260 }"#;
1261 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1262
1263 let assistant = output.as_assistant();
1264 assert!(assistant.is_some());
1265 assert_eq!(assistant.unwrap().message.model, "claude-sonnet-4");
1266
1267 let result_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 result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1279 assert!(result.as_assistant().is_none());
1280 }
1281
1282 #[test]
1283 fn test_as_result() {
1284 let json = r#"{
1285 "type": "result",
1286 "subtype": "success",
1287 "is_error": false,
1288 "duration_ms": 100,
1289 "duration_api_ms": 200,
1290 "num_turns": 5,
1291 "session_id": "abc",
1292 "total_cost_usd": 0.05
1293 }"#;
1294 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1295
1296 let result = output.as_result();
1297 assert!(result.is_some());
1298 assert_eq!(result.unwrap().num_turns, 5);
1299 assert_eq!(result.unwrap().total_cost_usd, 0.05);
1300
1301 let assistant_json = r#"{
1303 "type": "assistant",
1304 "message": {
1305 "id": "msg_1",
1306 "role": "assistant",
1307 "model": "claude-3",
1308 "content": []
1309 },
1310 "session_id": "abc"
1311 }"#;
1312 let assistant: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
1313 assert!(assistant.as_result().is_none());
1314 }
1315
1316 #[test]
1317 fn test_as_system() {
1318 let json = r#"{
1319 "type": "system",
1320 "subtype": "init",
1321 "session_id": "abc",
1322 "model": "claude-3"
1323 }"#;
1324 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1325
1326 let system = output.as_system();
1327 assert!(system.is_some());
1328 assert!(system.unwrap().is_init());
1329
1330 let result_json = r#"{
1332 "type": "result",
1333 "subtype": "success",
1334 "is_error": false,
1335 "duration_ms": 100,
1336 "duration_api_ms": 200,
1337 "num_turns": 1,
1338 "session_id": "abc",
1339 "total_cost_usd": 0.01
1340 }"#;
1341 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1342 assert!(result.as_system().is_none());
1343 }
1344}