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")]
117 pub user_message_uuid: Option<String>,
118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
123 pub user_message_uuids: Vec<String>,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ToolProgressMessage {
128 pub tool_use_id: String,
129 pub tool_name: String,
130 pub parent_tool_use_id: Option<String>,
131 pub elapsed_time_seconds: f64,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub task_id: Option<String>,
134 pub uuid: String,
135 pub session_id: String,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub heartbeat: Option<bool>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub subagent_type: Option<String>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub subagent_retry: Option<SubagentRetry>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct SubagentRetry {
152 pub agent_id: String,
153 pub attempt: u64,
154 pub max_retries: u64,
155 pub retry_delay_ms: u64,
156 pub error_status: Option<u16>,
157 pub error_category: String,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct CommandLifecycleMessage {
169 pub command_uuid: String,
172 pub state: CommandLifecycleState,
173 pub uuid: String,
174 pub session_id: String,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Hash)]
179pub enum CommandLifecycleState {
180 Queued,
182 Started,
184 Completed,
186 Cancelled,
189 Discarded,
191 Unknown(String),
193}
194
195impl CommandLifecycleState {
196 pub fn as_str(&self) -> &str {
197 match self {
198 Self::Queued => "queued",
199 Self::Started => "started",
200 Self::Completed => "completed",
201 Self::Cancelled => "cancelled",
202 Self::Discarded => "discarded",
203 Self::Unknown(s) => s.as_str(),
204 }
205 }
206}
207
208impl std::fmt::Display for CommandLifecycleState {
209 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210 f.write_str(self.as_str())
211 }
212}
213
214impl From<&str> for CommandLifecycleState {
215 fn from(s: &str) -> Self {
216 match s {
217 "queued" => Self::Queued,
218 "started" => Self::Started,
219 "completed" => Self::Completed,
220 "cancelled" => Self::Cancelled,
221 "discarded" => Self::Discarded,
222 other => Self::Unknown(other.to_string()),
223 }
224 }
225}
226
227impl Serialize for CommandLifecycleState {
228 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
229 serializer.serialize_str(self.as_str())
230 }
231}
232
233impl<'de> Deserialize<'de> for CommandLifecycleState {
234 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
235 let s = String::deserialize(deserializer)?;
236 Ok(Self::from(s.as_str()))
237 }
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct AuthStatusMessage {
242 #[serde(rename = "isAuthenticating")]
243 pub is_authenticating: bool,
244 pub output: Vec<String>,
245 #[serde(skip_serializing_if = "Option::is_none")]
246 pub error: Option<String>,
247 pub uuid: String,
248 pub session_id: String,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct ToolUseSummaryMessage {
253 pub summary: String,
254 pub preceding_tool_use_ids: Vec<String>,
255 pub uuid: String,
256 pub session_id: String,
257 #[serde(skip_serializing_if = "Option::is_none")]
258 pub timestamp: Option<String>,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct PromptSuggestionMessage {
263 pub suggestion: String,
264 pub uuid: String,
265 pub session_id: String,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct ConversationResetMessage {
287 pub new_conversation_id: String,
291 pub uuid: String,
292 pub session_id: String,
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct TranscriptMessage {
304 #[serde(flatten)]
305 pub data: Map<String, Value>,
306}
307
308impl ClaudeOutput {
309 pub fn message_type(&self) -> String {
311 match self {
312 ClaudeOutput::System(_) => "system".to_string(),
313 ClaudeOutput::User(_) => "user".to_string(),
314 ClaudeOutput::Assistant(_) => "assistant".to_string(),
315 ClaudeOutput::Result(_) => "result".to_string(),
316 ClaudeOutput::TranscriptResult(_) => "result".to_string(),
317 ClaudeOutput::ControlRequest(_) => "control_request".to_string(),
318 ClaudeOutput::ControlResponse(_) => "control_response".to_string(),
319 ClaudeOutput::Error(_) => "error".to_string(),
320 ClaudeOutput::RateLimitEvent(_) => "rate_limit_event".to_string(),
321 ClaudeOutput::StreamEvent(_) => "stream_event".to_string(),
322 ClaudeOutput::ToolProgress(_) => "tool_progress".to_string(),
323 ClaudeOutput::CommandLifecycle(_) => "command_lifecycle".to_string(),
324 ClaudeOutput::AuthStatus(_) => "auth_status".to_string(),
325 ClaudeOutput::ToolUseSummary(_) => "tool_use_summary".to_string(),
326 ClaudeOutput::PromptSuggestion(_) => "prompt_suggestion".to_string(),
327 ClaudeOutput::ConversationReset(_) => "conversation_reset".to_string(),
328 ClaudeOutput::Progress(_) => "progress".to_string(),
329 ClaudeOutput::QueueOperation(_) => "queue-operation".to_string(),
330 ClaudeOutput::PrLink(_) => "pr-link".to_string(),
331 ClaudeOutput::FileHistorySnapshot(_) => "file-history-snapshot".to_string(),
332 ClaudeOutput::Summary(_) => "summary".to_string(),
333 ClaudeOutput::Mode(_) => "mode".to_string(),
334 ClaudeOutput::PermissionMode(_) => "permission-mode".to_string(),
335 ClaudeOutput::Attachment(_) => "attachment".to_string(),
336 ClaudeOutput::AiTitle(_) => "ai-title".to_string(),
337 ClaudeOutput::LastPrompt(_) => "last-prompt".to_string(),
338 ClaudeOutput::Started(_) => "started".to_string(),
339 }
340 }
341
342 pub fn is_control_request(&self) -> bool {
344 matches!(self, ClaudeOutput::ControlRequest(_))
345 }
346
347 pub fn is_control_response(&self) -> bool {
349 matches!(self, ClaudeOutput::ControlResponse(_))
350 }
351
352 pub fn is_api_error(&self) -> bool {
354 matches!(self, ClaudeOutput::Error(_))
355 }
356
357 pub fn as_control_request(&self) -> Option<&ControlRequest> {
359 match self {
360 ClaudeOutput::ControlRequest(req) => Some(req),
361 _ => None,
362 }
363 }
364
365 pub fn as_anthropic_error(&self) -> Option<&AnthropicError> {
381 match self {
382 ClaudeOutput::Error(err) => Some(err),
383 _ => None,
384 }
385 }
386
387 pub fn is_rate_limit_event(&self) -> bool {
389 matches!(self, ClaudeOutput::RateLimitEvent(_))
390 }
391
392 pub fn as_rate_limit_event(&self) -> Option<&RateLimitEvent> {
394 match self {
395 ClaudeOutput::RateLimitEvent(evt) => Some(evt),
396 _ => None,
397 }
398 }
399
400 pub fn is_error(&self) -> bool {
402 matches!(self, ClaudeOutput::Result(r) if r.is_error)
403 }
404
405 pub fn is_assistant_message(&self) -> bool {
407 matches!(self, ClaudeOutput::Assistant(_))
408 }
409
410 pub fn is_system_message(&self) -> bool {
412 matches!(self, ClaudeOutput::System(_))
413 }
414
415 pub fn is_system_init(&self) -> bool {
426 matches!(self, ClaudeOutput::System(sys) if sys.is_init())
427 }
428
429 pub fn session_id(&self) -> Option<&str> {
445 match self {
446 ClaudeOutput::System(sys) => sys
447 .data
448 .get("session_id")
449 .or_else(|| sys.data.get("sessionId"))
450 .and_then(|v| v.as_str()),
451 ClaudeOutput::Assistant(ass) => Some(&ass.session_id),
452 ClaudeOutput::Result(res) => Some(&res.session_id),
453 ClaudeOutput::TranscriptResult(msg) => msg
454 .data
455 .get("session_id")
456 .or_else(|| msg.data.get("sessionId"))
457 .and_then(|v| v.as_str()),
458 ClaudeOutput::User(_) => None,
459 ClaudeOutput::ControlRequest(_) => None,
460 ClaudeOutput::ControlResponse(_) => None,
461 ClaudeOutput::Error(_) => None,
462 ClaudeOutput::RateLimitEvent(evt) => Some(&evt.session_id),
463 ClaudeOutput::StreamEvent(msg) => Some(&msg.session_id),
464 ClaudeOutput::ToolProgress(msg) => Some(&msg.session_id),
465 ClaudeOutput::CommandLifecycle(msg) => Some(&msg.session_id),
466 ClaudeOutput::AuthStatus(msg) => Some(&msg.session_id),
467 ClaudeOutput::ToolUseSummary(msg) => Some(&msg.session_id),
468 ClaudeOutput::PromptSuggestion(msg) => Some(&msg.session_id),
469 ClaudeOutput::ConversationReset(msg) => Some(&msg.session_id),
470 ClaudeOutput::Progress(msg)
471 | ClaudeOutput::QueueOperation(msg)
472 | ClaudeOutput::PrLink(msg)
473 | ClaudeOutput::FileHistorySnapshot(msg)
474 | ClaudeOutput::Summary(msg)
475 | ClaudeOutput::Mode(msg)
476 | ClaudeOutput::PermissionMode(msg)
477 | ClaudeOutput::Attachment(msg)
478 | ClaudeOutput::AiTitle(msg)
479 | ClaudeOutput::LastPrompt(msg)
480 | ClaudeOutput::Started(msg) => msg
481 .data
482 .get("session_id")
483 .or_else(|| msg.data.get("sessionId"))
484 .and_then(|v| v.as_str()),
485 }
486 }
487
488 pub fn as_tool_use(&self, tool_name: &str) -> Option<&ToolUseBlock> {
507 match self {
508 ClaudeOutput::Assistant(ass) => {
509 ass.message.content.iter().find_map(|block| match block {
510 ContentBlock::ToolUse(tu) if tu.name == tool_name => Some(tu),
511 _ => None,
512 })
513 }
514 _ => None,
515 }
516 }
517
518 pub fn tool_uses(&self) -> impl Iterator<Item = &ToolUseBlock> {
538 let content = match self {
539 ClaudeOutput::Assistant(ass) => Some(&ass.message.content),
540 _ => None,
541 };
542
543 content
544 .into_iter()
545 .flat_map(|c| c.iter())
546 .filter_map(|block| match block {
547 ContentBlock::ToolUse(tu) => Some(tu),
548 _ => None,
549 })
550 }
551
552 pub fn text_content(&self) -> Option<String> {
568 match self {
569 ClaudeOutput::Assistant(ass) => {
570 let texts: Vec<&str> = ass
571 .message
572 .content
573 .iter()
574 .filter_map(|block| match block {
575 ContentBlock::Text(t) => Some(t.text.as_str()),
576 _ => None,
577 })
578 .collect();
579
580 if texts.is_empty() {
581 None
582 } else {
583 Some(texts.join(""))
584 }
585 }
586 _ => None,
587 }
588 }
589
590 pub fn as_assistant(&self) -> Option<&AssistantMessage> {
605 match self {
606 ClaudeOutput::Assistant(ass) => Some(ass),
607 _ => None,
608 }
609 }
610
611 pub fn as_result(&self) -> Option<&ResultMessage> {
627 match self {
628 ClaudeOutput::Result(res) => Some(res),
629 _ => None,
630 }
631 }
632
633 pub fn as_system(&self) -> Option<&SystemMessage> {
635 match self {
636 ClaudeOutput::System(sys) => Some(sys),
637 _ => None,
638 }
639 }
640
641 pub fn parse_json_tolerant(s: &str) -> Result<ClaudeOutput, ParseError> {
646 match Self::parse_json(s) {
648 Ok(output) => Ok(output),
649 Err(first_error) => {
650 if let Some(json_start) = s.find('{') {
652 let trimmed = &s[json_start..];
653 match Self::parse_json(trimmed) {
654 Ok(output) => Ok(output),
655 Err(_) => {
656 Err(first_error)
658 }
659 }
660 } else {
661 Err(first_error)
662 }
663 }
664 }
665 }
666
667 pub fn parse_json(s: &str) -> Result<ClaudeOutput, ParseError> {
669 let value: Value = serde_json::from_str(s).map_err(|e| ParseError {
671 raw_line: s.to_string(),
672 raw_json: None,
673 error_message: format!("Invalid JSON: {}", e),
674 })?;
675
676 serde_json::from_value::<ClaudeOutput>(value.clone()).map_err(|e| ParseError {
678 raw_line: s.to_string(),
679 raw_json: Some(value),
680 error_message: e.to_string(),
681 })
682 }
683}
684
685impl<'de> Deserialize<'de> for ClaudeOutput {
686 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
687 let value = Value::deserialize(deserializer)?;
688 let message_type = value
689 .get("type")
690 .and_then(|v| v.as_str())
691 .ok_or_else(|| serde::de::Error::missing_field("type"))?;
692 let mut payload = value.clone();
693 if let Some(obj) = payload.as_object_mut() {
694 obj.remove("type");
695 }
696
697 fn parse<T, E>(value: Value) -> Result<T, E>
698 where
699 T: serde::de::DeserializeOwned,
700 E: serde::de::Error,
701 {
702 serde_json::from_value(value).map_err(E::custom)
703 }
704
705 match message_type {
706 "system" => parse(payload).map(Self::System),
707 "user" => parse(payload).map(Self::User),
708 "assistant" => parse(payload).map(Self::Assistant),
709 "result" if value.get("subtype").is_some() => parse(payload).map(Self::Result),
710 "result" => parse(payload).map(Self::TranscriptResult),
711 "control_request" => parse(payload).map(Self::ControlRequest),
712 "control_response" => parse(payload).map(Self::ControlResponse),
713 "error" => parse(payload).map(Self::Error),
714 "rate_limit_event" => parse(payload).map(Self::RateLimitEvent),
715 "stream_event" => parse(payload).map(Self::StreamEvent),
716 "tool_progress" => parse(payload).map(Self::ToolProgress),
717 "command_lifecycle" => parse(payload).map(Self::CommandLifecycle),
718 "auth_status" => parse(payload).map(Self::AuthStatus),
719 "tool_use_summary" => parse(payload).map(Self::ToolUseSummary),
720 "prompt_suggestion" => parse(payload).map(Self::PromptSuggestion),
721 "conversation_reset" => parse(payload).map(Self::ConversationReset),
722 "progress" => parse(payload).map(Self::Progress),
723 "queue-operation" => parse(payload).map(Self::QueueOperation),
724 "pr-link" => parse(payload).map(Self::PrLink),
725 "file-history-snapshot" => parse(payload).map(Self::FileHistorySnapshot),
726 "summary" => parse(payload).map(Self::Summary),
727 "mode" => parse(payload).map(Self::Mode),
728 "permission-mode" => parse(payload).map(Self::PermissionMode),
729 "attachment" => parse(payload).map(Self::Attachment),
730 "ai-title" => parse(payload).map(Self::AiTitle),
731 "last-prompt" => parse(payload).map(Self::LastPrompt),
732 "started" => parse(payload).map(Self::Started),
733 other => Err(serde::de::Error::unknown_variant(
734 other,
735 &[
736 "system",
737 "user",
738 "assistant",
739 "result",
740 "control_request",
741 "control_response",
742 "error",
743 "rate_limit_event",
744 "stream_event",
745 "tool_progress",
746 "command_lifecycle",
747 "auth_status",
748 "tool_use_summary",
749 "prompt_suggestion",
750 "conversation_reset",
751 "progress",
752 "queue-operation",
753 "pr-link",
754 "file-history-snapshot",
755 "summary",
756 "mode",
757 "permission-mode",
758 "attachment",
759 "ai-title",
760 "last-prompt",
761 "started",
762 ],
763 )),
764 }
765 }
766}
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771
772 #[test]
773 fn test_deserialize_assistant_message() {
774 let json = r#"{
775 "type": "assistant",
776 "message": {
777 "id": "msg_123",
778 "role": "assistant",
779 "model": "claude-3-sonnet",
780 "content": [{"type": "text", "text": "Hello! How can I help you?"}]
781 },
782 "session_id": "123"
783 }"#;
784
785 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
786 assert!(output.is_assistant_message());
787 }
788
789 #[test]
790 fn test_deserialize_new_top_level_message_types() {
791 let cases = [
792 (
793 r#"{"type":"stream_event","event":{"type":"content_block_delta"},"parent_tool_use_id":null,"uuid":"u1","session_id":"s1","ttft_ms":12}"#,
794 "stream_event",
795 ),
796 (
797 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"}"#,
798 "tool_progress",
799 ),
800 (
801 r#"{"type":"auth_status","isAuthenticating":true,"output":["login"],"uuid":"u3","session_id":"s3"}"#,
802 "auth_status",
803 ),
804 (
805 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"}"#,
806 "tool_use_summary",
807 ),
808 (
809 r#"{"type":"prompt_suggestion","suggestion":"Run tests","uuid":"u5","session_id":"s5"}"#,
810 "prompt_suggestion",
811 ),
812 (
813 r#"{"type":"conversation_reset","new_conversation_id":"new-session","uuid":"u6","session_id":"s6"}"#,
814 "conversation_reset",
815 ),
816 (
817 r#"{"type":"command_lifecycle","command_uuid":"cmd-1","state":"queued","uuid":"u7","session_id":"s7"}"#,
818 "command_lifecycle",
819 ),
820 ];
821
822 for (json, message_type) in cases {
823 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
824 assert_eq!(output.message_type(), message_type);
825 assert!(output.session_id().is_some());
826 }
827 }
828
829 #[test]
830 fn test_command_lifecycle_states_roundtrip() {
831 for state in ["queued", "started", "completed", "cancelled", "discarded"] {
832 let json = format!(
833 r#"{{"type":"command_lifecycle","command_uuid":"cmd-1","state":"{}","uuid":"u1","session_id":"s1"}}"#,
834 state
835 );
836 let output: ClaudeOutput = serde_json::from_str(&json).unwrap();
837 let ClaudeOutput::CommandLifecycle(msg) = &output else {
838 panic!("expected CommandLifecycle");
839 };
840 assert_eq!(msg.state.as_str(), state);
841 assert!(!matches!(msg.state, CommandLifecycleState::Unknown(_)));
842 assert_eq!(serde_json::to_string(&output).unwrap(), json);
843 }
844
845 let json = r#"{"type":"command_lifecycle","command_uuid":"cmd-2","state":"parked","uuid":"u2","session_id":"s2"}"#;
847 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
848 let ClaudeOutput::CommandLifecycle(msg) = &output else {
849 panic!("expected CommandLifecycle");
850 };
851 assert_eq!(
852 msg.state,
853 CommandLifecycleState::Unknown("parked".to_string())
854 );
855 assert_eq!(serde_json::to_string(&output).unwrap(), json);
856 }
857
858 #[test]
859 fn test_tool_progress_heartbeat_and_subagent_retry() {
860 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"}}"#;
861 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
862 let ClaudeOutput::ToolProgress(msg) = &output else {
863 panic!("expected ToolProgress");
864 };
865 assert_eq!(msg.heartbeat, Some(true));
866 assert_eq!(msg.subagent_type.as_deref(), Some("Explore"));
867 let retry = msg.subagent_retry.as_ref().unwrap();
868 assert_eq!(retry.attempt, 2);
869 assert_eq!(retry.error_status, Some(529));
870 assert_eq!(retry.error_category, "overloaded");
871
872 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"}}"#;
874 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
875 let ClaudeOutput::ToolProgress(msg) = &output else {
876 panic!("expected ToolProgress");
877 };
878 assert_eq!(msg.subagent_retry.as_ref().unwrap().error_status, None);
879 }
880
881 #[test]
882 fn test_deserialize_transcript_only_message_types() {
883 let cases = [
884 (
885 r#"{"type":"progress","sessionId":"s1","content":"delta"}"#,
886 "progress",
887 Some("s1"),
888 ),
889 (
890 r#"{"type":"queue-operation","sessionId":"s2","operation":"push"}"#,
891 "queue-operation",
892 Some("s2"),
893 ),
894 (
895 r#"{"type":"pr-link","sessionId":"s3","url":"https://example.invalid/pr"}"#,
896 "pr-link",
897 Some("s3"),
898 ),
899 (
900 r#"{"type":"file-history-snapshot","sessionId":"s4","files":[]}"#,
901 "file-history-snapshot",
902 Some("s4"),
903 ),
904 (r#"{"type":"summary","summary":"Done"}"#, "summary", None),
905 (
906 r#"{"type":"mode","mode":"normal","sessionId":"s5"}"#,
907 "mode",
908 Some("s5"),
909 ),
910 (
911 r#"{"type":"permission-mode","permissionMode":"default","sessionId":"s6"}"#,
912 "permission-mode",
913 Some("s6"),
914 ),
915 (
916 r#"{"type":"attachment","attachment":{"type":"task_reminder"},"sessionId":"s7"}"#,
917 "attachment",
918 Some("s7"),
919 ),
920 (
921 r#"{"type":"ai-title","aiTitle":"Title","sessionId":"s8"}"#,
922 "ai-title",
923 Some("s8"),
924 ),
925 (
926 r#"{"type":"last-prompt","lastPrompt":"prompt","sessionId":"s9"}"#,
927 "last-prompt",
928 Some("s9"),
929 ),
930 (
931 r#"{"type":"started","sessionId":"s10"}"#,
932 "started",
933 Some("s10"),
934 ),
935 (
936 r#"{"type":"result","key":"k","agentId":"a","result":{"ok":true}}"#,
937 "result",
938 None,
939 ),
940 ];
941
942 for (json, message_type, session_id) in cases {
943 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
944 assert_eq!(output.message_type(), message_type);
945 assert_eq!(output.session_id(), session_id);
946 }
947 }
948
949 #[test]
950 fn test_transcript_assistant_accepts_camel_case_session_id() {
951 let json = r#"{
952 "type": "assistant",
953 "sessionId": "camel-session",
954 "message": {
955 "id": "msg_123",
956 "role": "assistant",
957 "model": "claude-3-sonnet",
958 "content": [{"type": "text", "text": "Hello"}]
959 }
960 }"#;
961
962 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
963 assert_eq!(output.session_id(), Some("camel-session"));
964 }
965
966 #[test]
967 fn test_is_system_init() {
968 let init_json = r#"{
969 "type": "system",
970 "subtype": "init",
971 "session_id": "test-session"
972 }"#;
973 let output: ClaudeOutput = serde_json::from_str(init_json).unwrap();
974 assert!(output.is_system_init());
975
976 let status_json = r#"{
977 "type": "system",
978 "subtype": "status",
979 "session_id": "test-session"
980 }"#;
981 let output: ClaudeOutput = serde_json::from_str(status_json).unwrap();
982 assert!(!output.is_system_init());
983 }
984
985 #[test]
986 fn test_session_id() {
987 let result_json = r#"{
989 "type": "result",
990 "subtype": "success",
991 "is_error": false,
992 "duration_ms": 100,
993 "duration_api_ms": 200,
994 "num_turns": 1,
995 "session_id": "result-session",
996 "total_cost_usd": 0.01
997 }"#;
998 let output: ClaudeOutput = serde_json::from_str(result_json).unwrap();
999 assert_eq!(output.session_id(), Some("result-session"));
1000
1001 let assistant_json = r#"{
1003 "type": "assistant",
1004 "message": {
1005 "id": "msg_1",
1006 "role": "assistant",
1007 "model": "claude-3",
1008 "content": []
1009 },
1010 "session_id": "assistant-session"
1011 }"#;
1012 let output: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
1013 assert_eq!(output.session_id(), Some("assistant-session"));
1014
1015 let system_json = r#"{
1017 "type": "system",
1018 "subtype": "init",
1019 "session_id": "system-session"
1020 }"#;
1021 let output: ClaudeOutput = serde_json::from_str(system_json).unwrap();
1022 assert_eq!(output.session_id(), Some("system-session"));
1023 }
1024
1025 #[test]
1026 fn test_as_tool_use() {
1027 let json = r#"{
1028 "type": "assistant",
1029 "message": {
1030 "id": "msg_1",
1031 "role": "assistant",
1032 "model": "claude-3",
1033 "content": [
1034 {"type": "text", "text": "Let me run that command."},
1035 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls -la"}},
1036 {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/test"}}
1037 ]
1038 },
1039 "session_id": "abc"
1040 }"#;
1041 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1042
1043 let bash = output.as_tool_use("Bash");
1045 assert!(bash.is_some());
1046 assert_eq!(bash.unwrap().id, "tu_1");
1047
1048 let read = output.as_tool_use("Read");
1050 assert!(read.is_some());
1051 assert_eq!(read.unwrap().id, "tu_2");
1052
1053 assert!(output.as_tool_use("Write").is_none());
1055
1056 let result_json = r#"{
1058 "type": "result",
1059 "subtype": "success",
1060 "is_error": false,
1061 "duration_ms": 100,
1062 "duration_api_ms": 200,
1063 "num_turns": 1,
1064 "session_id": "abc",
1065 "total_cost_usd": 0.01
1066 }"#;
1067 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1068 assert!(result.as_tool_use("Bash").is_none());
1069 }
1070
1071 #[test]
1072 fn test_tool_uses() {
1073 let json = r#"{
1074 "type": "assistant",
1075 "message": {
1076 "id": "msg_1",
1077 "role": "assistant",
1078 "model": "claude-3",
1079 "content": [
1080 {"type": "text", "text": "Running commands..."},
1081 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls"}},
1082 {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/a"}},
1083 {"type": "tool_use", "id": "tu_3", "name": "Write", "input": {"file_path": "/tmp/b", "content": "x"}}
1084 ]
1085 },
1086 "session_id": "abc"
1087 }"#;
1088 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1089
1090 let tools: Vec<_> = output.tool_uses().collect();
1091 assert_eq!(tools.len(), 3);
1092 assert_eq!(tools[0].name, "Bash");
1093 assert_eq!(tools[1].name, "Read");
1094 assert_eq!(tools[2].name, "Write");
1095 }
1096
1097 #[test]
1098 fn test_text_content() {
1099 let json = r#"{
1101 "type": "assistant",
1102 "message": {
1103 "id": "msg_1",
1104 "role": "assistant",
1105 "model": "claude-3",
1106 "content": [{"type": "text", "text": "Hello, world!"}]
1107 },
1108 "session_id": "abc"
1109 }"#;
1110 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1111 assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
1112
1113 let json = r#"{
1115 "type": "assistant",
1116 "message": {
1117 "id": "msg_1",
1118 "role": "assistant",
1119 "model": "claude-3",
1120 "content": [
1121 {"type": "text", "text": "Hello, "},
1122 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}},
1123 {"type": "text", "text": "world!"}
1124 ]
1125 },
1126 "session_id": "abc"
1127 }"#;
1128 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1129 assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
1130
1131 let json = r#"{
1133 "type": "assistant",
1134 "message": {
1135 "id": "msg_1",
1136 "role": "assistant",
1137 "model": "claude-3",
1138 "content": [{"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}}]
1139 },
1140 "session_id": "abc"
1141 }"#;
1142 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1143 assert_eq!(output.text_content(), None);
1144
1145 let 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 output: ClaudeOutput = serde_json::from_str(json).unwrap();
1157 assert_eq!(output.text_content(), None);
1158 }
1159
1160 #[test]
1161 fn test_as_assistant() {
1162 let json = r#"{
1163 "type": "assistant",
1164 "message": {
1165 "id": "msg_1",
1166 "role": "assistant",
1167 "model": "claude-sonnet-4",
1168 "content": []
1169 },
1170 "session_id": "abc"
1171 }"#;
1172 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1173
1174 let assistant = output.as_assistant();
1175 assert!(assistant.is_some());
1176 assert_eq!(assistant.unwrap().message.model, "claude-sonnet-4");
1177
1178 let result_json = r#"{
1180 "type": "result",
1181 "subtype": "success",
1182 "is_error": false,
1183 "duration_ms": 100,
1184 "duration_api_ms": 200,
1185 "num_turns": 1,
1186 "session_id": "abc",
1187 "total_cost_usd": 0.01
1188 }"#;
1189 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1190 assert!(result.as_assistant().is_none());
1191 }
1192
1193 #[test]
1194 fn test_as_result() {
1195 let json = r#"{
1196 "type": "result",
1197 "subtype": "success",
1198 "is_error": false,
1199 "duration_ms": 100,
1200 "duration_api_ms": 200,
1201 "num_turns": 5,
1202 "session_id": "abc",
1203 "total_cost_usd": 0.05
1204 }"#;
1205 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1206
1207 let result = output.as_result();
1208 assert!(result.is_some());
1209 assert_eq!(result.unwrap().num_turns, 5);
1210 assert_eq!(result.unwrap().total_cost_usd, 0.05);
1211
1212 let assistant_json = r#"{
1214 "type": "assistant",
1215 "message": {
1216 "id": "msg_1",
1217 "role": "assistant",
1218 "model": "claude-3",
1219 "content": []
1220 },
1221 "session_id": "abc"
1222 }"#;
1223 let assistant: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
1224 assert!(assistant.as_result().is_none());
1225 }
1226
1227 #[test]
1228 fn test_as_system() {
1229 let json = r#"{
1230 "type": "system",
1231 "subtype": "init",
1232 "session_id": "abc",
1233 "model": "claude-3"
1234 }"#;
1235 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1236
1237 let system = output.as_system();
1238 assert!(system.is_some());
1239 assert!(system.unwrap().is_init());
1240
1241 let result_json = r#"{
1243 "type": "result",
1244 "subtype": "success",
1245 "is_error": false,
1246 "duration_ms": 100,
1247 "duration_api_ms": 200,
1248 "num_turns": 1,
1249 "session_id": "abc",
1250 "total_cost_usd": 0.01
1251 }"#;
1252 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1253 assert!(result.as_system().is_none());
1254 }
1255}