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}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct TranscriptMessage {
314 #[serde(flatten)]
315 pub data: Map<String, Value>,
316}
317
318impl ClaudeOutput {
319 pub fn message_type(&self) -> String {
321 match self {
322 ClaudeOutput::System(_) => "system".to_string(),
323 ClaudeOutput::User(_) => "user".to_string(),
324 ClaudeOutput::Assistant(_) => "assistant".to_string(),
325 ClaudeOutput::Result(_) => "result".to_string(),
326 ClaudeOutput::TranscriptResult(_) => "result".to_string(),
327 ClaudeOutput::ControlRequest(_) => "control_request".to_string(),
328 ClaudeOutput::ControlResponse(_) => "control_response".to_string(),
329 ClaudeOutput::Error(_) => "error".to_string(),
330 ClaudeOutput::RateLimitEvent(_) => "rate_limit_event".to_string(),
331 ClaudeOutput::StreamEvent(_) => "stream_event".to_string(),
332 ClaudeOutput::ToolProgress(_) => "tool_progress".to_string(),
333 ClaudeOutput::CommandLifecycle(_) => "command_lifecycle".to_string(),
334 ClaudeOutput::AuthStatus(_) => "auth_status".to_string(),
335 ClaudeOutput::ToolUseSummary(_) => "tool_use_summary".to_string(),
336 ClaudeOutput::PromptSuggestion(_) => "prompt_suggestion".to_string(),
337 ClaudeOutput::ConversationReset(_) => "conversation_reset".to_string(),
338 ClaudeOutput::Progress(_) => "progress".to_string(),
339 ClaudeOutput::QueueOperation(_) => "queue-operation".to_string(),
340 ClaudeOutput::PrLink(_) => "pr-link".to_string(),
341 ClaudeOutput::FileHistorySnapshot(_) => "file-history-snapshot".to_string(),
342 ClaudeOutput::Summary(_) => "summary".to_string(),
343 ClaudeOutput::Mode(_) => "mode".to_string(),
344 ClaudeOutput::PermissionMode(_) => "permission-mode".to_string(),
345 ClaudeOutput::Attachment(_) => "attachment".to_string(),
346 ClaudeOutput::AiTitle(_) => "ai-title".to_string(),
347 ClaudeOutput::LastPrompt(_) => "last-prompt".to_string(),
348 ClaudeOutput::Started(_) => "started".to_string(),
349 }
350 }
351
352 pub fn is_control_request(&self) -> bool {
354 matches!(self, ClaudeOutput::ControlRequest(_))
355 }
356
357 pub fn is_control_response(&self) -> bool {
359 matches!(self, ClaudeOutput::ControlResponse(_))
360 }
361
362 pub fn is_api_error(&self) -> bool {
364 matches!(self, ClaudeOutput::Error(_))
365 }
366
367 pub fn as_control_request(&self) -> Option<&ControlRequest> {
369 match self {
370 ClaudeOutput::ControlRequest(req) => Some(req),
371 _ => None,
372 }
373 }
374
375 pub fn as_anthropic_error(&self) -> Option<&AnthropicError> {
391 match self {
392 ClaudeOutput::Error(err) => Some(err),
393 _ => None,
394 }
395 }
396
397 pub fn is_rate_limit_event(&self) -> bool {
399 matches!(self, ClaudeOutput::RateLimitEvent(_))
400 }
401
402 pub fn as_rate_limit_event(&self) -> Option<&RateLimitEvent> {
404 match self {
405 ClaudeOutput::RateLimitEvent(evt) => Some(evt),
406 _ => None,
407 }
408 }
409
410 pub fn is_error(&self) -> bool {
412 matches!(self, ClaudeOutput::Result(r) if r.is_error)
413 }
414
415 pub fn is_assistant_message(&self) -> bool {
417 matches!(self, ClaudeOutput::Assistant(_))
418 }
419
420 pub fn is_system_message(&self) -> bool {
422 matches!(self, ClaudeOutput::System(_))
423 }
424
425 pub fn is_system_init(&self) -> bool {
436 matches!(self, ClaudeOutput::System(sys) if sys.is_init())
437 }
438
439 pub fn session_id(&self) -> Option<&str> {
455 match self {
456 ClaudeOutput::System(sys) => sys
457 .data
458 .get("session_id")
459 .or_else(|| sys.data.get("sessionId"))
460 .and_then(|v| v.as_str()),
461 ClaudeOutput::Assistant(ass) => Some(&ass.session_id),
462 ClaudeOutput::Result(res) => Some(&res.session_id),
463 ClaudeOutput::TranscriptResult(msg) => msg
464 .data
465 .get("session_id")
466 .or_else(|| msg.data.get("sessionId"))
467 .and_then(|v| v.as_str()),
468 ClaudeOutput::User(_) => None,
469 ClaudeOutput::ControlRequest(_) => None,
470 ClaudeOutput::ControlResponse(_) => None,
471 ClaudeOutput::Error(_) => None,
472 ClaudeOutput::RateLimitEvent(evt) => Some(&evt.session_id),
473 ClaudeOutput::StreamEvent(msg) => Some(&msg.session_id),
474 ClaudeOutput::ToolProgress(msg) => Some(&msg.session_id),
475 ClaudeOutput::CommandLifecycle(msg) => Some(&msg.session_id),
476 ClaudeOutput::AuthStatus(msg) => Some(&msg.session_id),
477 ClaudeOutput::ToolUseSummary(msg) => Some(&msg.session_id),
478 ClaudeOutput::PromptSuggestion(msg) => Some(&msg.session_id),
479 ClaudeOutput::ConversationReset(msg) => Some(&msg.session_id),
480 ClaudeOutput::Progress(msg)
481 | ClaudeOutput::QueueOperation(msg)
482 | ClaudeOutput::PrLink(msg)
483 | ClaudeOutput::FileHistorySnapshot(msg)
484 | ClaudeOutput::Summary(msg)
485 | ClaudeOutput::Mode(msg)
486 | ClaudeOutput::PermissionMode(msg)
487 | ClaudeOutput::Attachment(msg)
488 | ClaudeOutput::AiTitle(msg)
489 | ClaudeOutput::LastPrompt(msg)
490 | ClaudeOutput::Started(msg) => msg
491 .data
492 .get("session_id")
493 .or_else(|| msg.data.get("sessionId"))
494 .and_then(|v| v.as_str()),
495 }
496 }
497
498 pub fn as_tool_use(&self, tool_name: &str) -> Option<&ToolUseBlock> {
517 match self {
518 ClaudeOutput::Assistant(ass) => {
519 ass.message.content.iter().find_map(|block| match block {
520 ContentBlock::ToolUse(tu) if tu.name == tool_name => Some(tu),
521 _ => None,
522 })
523 }
524 _ => None,
525 }
526 }
527
528 pub fn tool_uses(&self) -> impl Iterator<Item = &ToolUseBlock> {
548 let content = match self {
549 ClaudeOutput::Assistant(ass) => Some(&ass.message.content),
550 _ => None,
551 };
552
553 content
554 .into_iter()
555 .flat_map(|c| c.iter())
556 .filter_map(|block| match block {
557 ContentBlock::ToolUse(tu) => Some(tu),
558 _ => None,
559 })
560 }
561
562 pub fn text_content(&self) -> Option<String> {
578 match self {
579 ClaudeOutput::Assistant(ass) => {
580 let texts: Vec<&str> = ass
581 .message
582 .content
583 .iter()
584 .filter_map(|block| match block {
585 ContentBlock::Text(t) => Some(t.text.as_str()),
586 _ => None,
587 })
588 .collect();
589
590 if texts.is_empty() {
591 None
592 } else {
593 Some(texts.join(""))
594 }
595 }
596 _ => None,
597 }
598 }
599
600 pub fn as_assistant(&self) -> Option<&AssistantMessage> {
615 match self {
616 ClaudeOutput::Assistant(ass) => Some(ass),
617 _ => None,
618 }
619 }
620
621 pub fn as_result(&self) -> Option<&ResultMessage> {
637 match self {
638 ClaudeOutput::Result(res) => Some(res),
639 _ => None,
640 }
641 }
642
643 pub fn as_system(&self) -> Option<&SystemMessage> {
645 match self {
646 ClaudeOutput::System(sys) => Some(sys),
647 _ => None,
648 }
649 }
650
651 pub fn parse_json_tolerant(s: &str) -> Result<ClaudeOutput, ParseError> {
656 match Self::parse_json(s) {
658 Ok(output) => Ok(output),
659 Err(first_error) => {
660 if let Some(json_start) = s.find('{') {
662 let trimmed = &s[json_start..];
663 match Self::parse_json(trimmed) {
664 Ok(output) => Ok(output),
665 Err(_) => {
666 Err(first_error)
668 }
669 }
670 } else {
671 Err(first_error)
672 }
673 }
674 }
675 }
676
677 pub fn parse_json(s: &str) -> Result<ClaudeOutput, ParseError> {
679 let value: Value = serde_json::from_str(s).map_err(|e| ParseError {
681 raw_line: s.to_string(),
682 raw_json: None,
683 error_message: format!("Invalid JSON: {}", e),
684 })?;
685
686 serde_json::from_value::<ClaudeOutput>(value.clone()).map_err(|e| ParseError {
688 raw_line: s.to_string(),
689 raw_json: Some(value),
690 error_message: e.to_string(),
691 })
692 }
693}
694
695impl<'de> Deserialize<'de> for ClaudeOutput {
696 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
697 let value = Value::deserialize(deserializer)?;
698 let message_type = value
699 .get("type")
700 .and_then(|v| v.as_str())
701 .ok_or_else(|| serde::de::Error::missing_field("type"))?;
702 let mut payload = value.clone();
703 if let Some(obj) = payload.as_object_mut() {
704 obj.remove("type");
705 }
706
707 fn parse<T, E>(value: Value) -> Result<T, E>
708 where
709 T: serde::de::DeserializeOwned,
710 E: serde::de::Error,
711 {
712 serde_json::from_value(value).map_err(E::custom)
713 }
714
715 match message_type {
716 "system" => parse(payload).map(Self::System),
717 "user" => parse(payload).map(Self::User),
718 "assistant" => parse(payload).map(Self::Assistant),
719 "result" if value.get("subtype").is_some() => parse(payload).map(Self::Result),
720 "result" => parse(payload).map(Self::TranscriptResult),
721 "control_request" => parse(payload).map(Self::ControlRequest),
722 "control_response" => parse(payload).map(Self::ControlResponse),
723 "error" => parse(payload).map(Self::Error),
724 "rate_limit_event" => parse(payload).map(Self::RateLimitEvent),
725 "stream_event" => parse(payload).map(Self::StreamEvent),
726 "tool_progress" => parse(payload).map(Self::ToolProgress),
727 "command_lifecycle" => parse(payload).map(Self::CommandLifecycle),
728 "auth_status" => parse(payload).map(Self::AuthStatus),
729 "tool_use_summary" => parse(payload).map(Self::ToolUseSummary),
730 "prompt_suggestion" => parse(payload).map(Self::PromptSuggestion),
731 "conversation_reset" => parse(payload).map(Self::ConversationReset),
732 "progress" => parse(payload).map(Self::Progress),
733 "queue-operation" => parse(payload).map(Self::QueueOperation),
734 "pr-link" => parse(payload).map(Self::PrLink),
735 "file-history-snapshot" => parse(payload).map(Self::FileHistorySnapshot),
736 "summary" => parse(payload).map(Self::Summary),
737 "mode" => parse(payload).map(Self::Mode),
738 "permission-mode" => parse(payload).map(Self::PermissionMode),
739 "attachment" => parse(payload).map(Self::Attachment),
740 "ai-title" => parse(payload).map(Self::AiTitle),
741 "last-prompt" => parse(payload).map(Self::LastPrompt),
742 "started" => parse(payload).map(Self::Started),
743 other => Err(serde::de::Error::unknown_variant(
744 other,
745 &[
746 "system",
747 "user",
748 "assistant",
749 "result",
750 "control_request",
751 "control_response",
752 "error",
753 "rate_limit_event",
754 "stream_event",
755 "tool_progress",
756 "command_lifecycle",
757 "auth_status",
758 "tool_use_summary",
759 "prompt_suggestion",
760 "conversation_reset",
761 "progress",
762 "queue-operation",
763 "pr-link",
764 "file-history-snapshot",
765 "summary",
766 "mode",
767 "permission-mode",
768 "attachment",
769 "ai-title",
770 "last-prompt",
771 "started",
772 ],
773 )),
774 }
775 }
776}
777
778#[cfg(test)]
779mod tests {
780 use super::*;
781
782 #[test]
783 fn test_deserialize_assistant_message() {
784 let json = r#"{
785 "type": "assistant",
786 "message": {
787 "id": "msg_123",
788 "role": "assistant",
789 "model": "claude-3-sonnet",
790 "content": [{"type": "text", "text": "Hello! How can I help you?"}]
791 },
792 "session_id": "123"
793 }"#;
794
795 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
796 assert!(output.is_assistant_message());
797 }
798
799 #[test]
800 fn test_deserialize_new_top_level_message_types() {
801 let cases = [
802 (
803 r#"{"type":"stream_event","event":{"type":"content_block_delta"},"parent_tool_use_id":null,"uuid":"u1","session_id":"s1","ttft_ms":12}"#,
804 "stream_event",
805 ),
806 (
807 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"}"#,
808 "tool_progress",
809 ),
810 (
811 r#"{"type":"auth_status","isAuthenticating":true,"output":["login"],"uuid":"u3","session_id":"s3"}"#,
812 "auth_status",
813 ),
814 (
815 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"}"#,
816 "tool_use_summary",
817 ),
818 (
819 r#"{"type":"prompt_suggestion","suggestion":"Run tests","uuid":"u5","session_id":"s5"}"#,
820 "prompt_suggestion",
821 ),
822 (
823 r#"{"type":"conversation_reset","new_conversation_id":"new-session","uuid":"u6","session_id":"s6"}"#,
824 "conversation_reset",
825 ),
826 (
827 r#"{"type":"command_lifecycle","command_uuid":"cmd-1","state":"queued","uuid":"u7","session_id":"s7"}"#,
828 "command_lifecycle",
829 ),
830 ];
831
832 for (json, message_type) in cases {
833 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
834 assert_eq!(output.message_type(), message_type);
835 assert!(output.session_id().is_some());
836 }
837 }
838
839 #[test]
840 fn test_command_lifecycle_states_roundtrip() {
841 for state in ["queued", "started", "completed", "cancelled", "discarded"] {
842 let json = format!(
843 r#"{{"type":"command_lifecycle","command_uuid":"cmd-1","state":"{}","uuid":"u1","session_id":"s1"}}"#,
844 state
845 );
846 let output: ClaudeOutput = serde_json::from_str(&json).unwrap();
847 let ClaudeOutput::CommandLifecycle(msg) = &output else {
848 panic!("expected CommandLifecycle");
849 };
850 assert_eq!(msg.state.as_str(), state);
851 assert!(!matches!(msg.state, CommandLifecycleState::Unknown(_)));
852 assert_eq!(serde_json::to_string(&output).unwrap(), json);
853 }
854
855 let json = r#"{"type":"command_lifecycle","command_uuid":"cmd-2","state":"parked","uuid":"u2","session_id":"s2"}"#;
857 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
858 let ClaudeOutput::CommandLifecycle(msg) = &output else {
859 panic!("expected CommandLifecycle");
860 };
861 assert_eq!(
862 msg.state,
863 CommandLifecycleState::Unknown("parked".to_string())
864 );
865 assert_eq!(serde_json::to_string(&output).unwrap(), json);
866 }
867
868 #[test]
869 fn test_tool_progress_heartbeat_and_subagent_retry() {
870 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"}}"#;
871 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
872 let ClaudeOutput::ToolProgress(msg) = &output else {
873 panic!("expected ToolProgress");
874 };
875 assert_eq!(msg.heartbeat, Some(true));
876 assert_eq!(msg.subagent_type.as_deref(), Some("Explore"));
877 let retry = msg.subagent_retry.as_ref().unwrap();
878 assert_eq!(retry.attempt, 2);
879 assert_eq!(retry.error_status, Some(529));
880 assert_eq!(retry.error_category, "overloaded");
881
882 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"}}"#;
884 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
885 let ClaudeOutput::ToolProgress(msg) = &output else {
886 panic!("expected ToolProgress");
887 };
888 assert_eq!(msg.subagent_retry.as_ref().unwrap().error_status, None);
889 }
890
891 #[test]
892 fn test_deserialize_transcript_only_message_types() {
893 let cases = [
894 (
895 r#"{"type":"progress","sessionId":"s1","content":"delta"}"#,
896 "progress",
897 Some("s1"),
898 ),
899 (
900 r#"{"type":"queue-operation","sessionId":"s2","operation":"push"}"#,
901 "queue-operation",
902 Some("s2"),
903 ),
904 (
905 r#"{"type":"pr-link","sessionId":"s3","url":"https://example.invalid/pr"}"#,
906 "pr-link",
907 Some("s3"),
908 ),
909 (
910 r#"{"type":"file-history-snapshot","sessionId":"s4","files":[]}"#,
911 "file-history-snapshot",
912 Some("s4"),
913 ),
914 (r#"{"type":"summary","summary":"Done"}"#, "summary", None),
915 (
916 r#"{"type":"mode","mode":"normal","sessionId":"s5"}"#,
917 "mode",
918 Some("s5"),
919 ),
920 (
921 r#"{"type":"permission-mode","permissionMode":"default","sessionId":"s6"}"#,
922 "permission-mode",
923 Some("s6"),
924 ),
925 (
926 r#"{"type":"attachment","attachment":{"type":"task_reminder"},"sessionId":"s7"}"#,
927 "attachment",
928 Some("s7"),
929 ),
930 (
931 r#"{"type":"ai-title","aiTitle":"Title","sessionId":"s8"}"#,
932 "ai-title",
933 Some("s8"),
934 ),
935 (
936 r#"{"type":"last-prompt","lastPrompt":"prompt","sessionId":"s9"}"#,
937 "last-prompt",
938 Some("s9"),
939 ),
940 (
941 r#"{"type":"started","sessionId":"s10"}"#,
942 "started",
943 Some("s10"),
944 ),
945 (
946 r#"{"type":"result","key":"k","agentId":"a","result":{"ok":true}}"#,
947 "result",
948 None,
949 ),
950 ];
951
952 for (json, message_type, session_id) in cases {
953 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
954 assert_eq!(output.message_type(), message_type);
955 assert_eq!(output.session_id(), session_id);
956 }
957 }
958
959 #[test]
960 fn test_transcript_assistant_accepts_camel_case_session_id() {
961 let json = r#"{
962 "type": "assistant",
963 "sessionId": "camel-session",
964 "message": {
965 "id": "msg_123",
966 "role": "assistant",
967 "model": "claude-3-sonnet",
968 "content": [{"type": "text", "text": "Hello"}]
969 }
970 }"#;
971
972 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
973 assert_eq!(output.session_id(), Some("camel-session"));
974 }
975
976 #[test]
977 fn test_is_system_init() {
978 let init_json = r#"{
979 "type": "system",
980 "subtype": "init",
981 "session_id": "test-session"
982 }"#;
983 let output: ClaudeOutput = serde_json::from_str(init_json).unwrap();
984 assert!(output.is_system_init());
985
986 let status_json = r#"{
987 "type": "system",
988 "subtype": "status",
989 "session_id": "test-session"
990 }"#;
991 let output: ClaudeOutput = serde_json::from_str(status_json).unwrap();
992 assert!(!output.is_system_init());
993 }
994
995 #[test]
996 fn test_session_id() {
997 let result_json = r#"{
999 "type": "result",
1000 "subtype": "success",
1001 "is_error": false,
1002 "duration_ms": 100,
1003 "duration_api_ms": 200,
1004 "num_turns": 1,
1005 "session_id": "result-session",
1006 "total_cost_usd": 0.01
1007 }"#;
1008 let output: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1009 assert_eq!(output.session_id(), Some("result-session"));
1010
1011 let assistant_json = r#"{
1013 "type": "assistant",
1014 "message": {
1015 "id": "msg_1",
1016 "role": "assistant",
1017 "model": "claude-3",
1018 "content": []
1019 },
1020 "session_id": "assistant-session"
1021 }"#;
1022 let output: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
1023 assert_eq!(output.session_id(), Some("assistant-session"));
1024
1025 let system_json = r#"{
1027 "type": "system",
1028 "subtype": "init",
1029 "session_id": "system-session"
1030 }"#;
1031 let output: ClaudeOutput = serde_json::from_str(system_json).unwrap();
1032 assert_eq!(output.session_id(), Some("system-session"));
1033 }
1034
1035 #[test]
1036 fn test_as_tool_use() {
1037 let json = r#"{
1038 "type": "assistant",
1039 "message": {
1040 "id": "msg_1",
1041 "role": "assistant",
1042 "model": "claude-3",
1043 "content": [
1044 {"type": "text", "text": "Let me run that command."},
1045 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls -la"}},
1046 {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/test"}}
1047 ]
1048 },
1049 "session_id": "abc"
1050 }"#;
1051 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1052
1053 let bash = output.as_tool_use("Bash");
1055 assert!(bash.is_some());
1056 assert_eq!(bash.unwrap().id, "tu_1");
1057
1058 let read = output.as_tool_use("Read");
1060 assert!(read.is_some());
1061 assert_eq!(read.unwrap().id, "tu_2");
1062
1063 assert!(output.as_tool_use("Write").is_none());
1065
1066 let result_json = r#"{
1068 "type": "result",
1069 "subtype": "success",
1070 "is_error": false,
1071 "duration_ms": 100,
1072 "duration_api_ms": 200,
1073 "num_turns": 1,
1074 "session_id": "abc",
1075 "total_cost_usd": 0.01
1076 }"#;
1077 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1078 assert!(result.as_tool_use("Bash").is_none());
1079 }
1080
1081 #[test]
1082 fn test_tool_uses() {
1083 let json = r#"{
1084 "type": "assistant",
1085 "message": {
1086 "id": "msg_1",
1087 "role": "assistant",
1088 "model": "claude-3",
1089 "content": [
1090 {"type": "text", "text": "Running commands..."},
1091 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls"}},
1092 {"type": "tool_use", "id": "tu_2", "name": "Read", "input": {"file_path": "/tmp/a"}},
1093 {"type": "tool_use", "id": "tu_3", "name": "Write", "input": {"file_path": "/tmp/b", "content": "x"}}
1094 ]
1095 },
1096 "session_id": "abc"
1097 }"#;
1098 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1099
1100 let tools: Vec<_> = output.tool_uses().collect();
1101 assert_eq!(tools.len(), 3);
1102 assert_eq!(tools[0].name, "Bash");
1103 assert_eq!(tools[1].name, "Read");
1104 assert_eq!(tools[2].name, "Write");
1105 }
1106
1107 #[test]
1108 fn test_text_content() {
1109 let json = r#"{
1111 "type": "assistant",
1112 "message": {
1113 "id": "msg_1",
1114 "role": "assistant",
1115 "model": "claude-3",
1116 "content": [{"type": "text", "text": "Hello, world!"}]
1117 },
1118 "session_id": "abc"
1119 }"#;
1120 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1121 assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
1122
1123 let json = r#"{
1125 "type": "assistant",
1126 "message": {
1127 "id": "msg_1",
1128 "role": "assistant",
1129 "model": "claude-3",
1130 "content": [
1131 {"type": "text", "text": "Hello, "},
1132 {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}},
1133 {"type": "text", "text": "world!"}
1134 ]
1135 },
1136 "session_id": "abc"
1137 }"#;
1138 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1139 assert_eq!(output.text_content(), Some("Hello, world!".to_string()));
1140
1141 let json = r#"{
1143 "type": "assistant",
1144 "message": {
1145 "id": "msg_1",
1146 "role": "assistant",
1147 "model": "claude-3",
1148 "content": [{"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {}}]
1149 },
1150 "session_id": "abc"
1151 }"#;
1152 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1153 assert_eq!(output.text_content(), None);
1154
1155 let 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 output: ClaudeOutput = serde_json::from_str(json).unwrap();
1167 assert_eq!(output.text_content(), None);
1168 }
1169
1170 #[test]
1171 fn test_as_assistant() {
1172 let json = r#"{
1173 "type": "assistant",
1174 "message": {
1175 "id": "msg_1",
1176 "role": "assistant",
1177 "model": "claude-sonnet-4",
1178 "content": []
1179 },
1180 "session_id": "abc"
1181 }"#;
1182 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1183
1184 let assistant = output.as_assistant();
1185 assert!(assistant.is_some());
1186 assert_eq!(assistant.unwrap().message.model, "claude-sonnet-4");
1187
1188 let result_json = r#"{
1190 "type": "result",
1191 "subtype": "success",
1192 "is_error": false,
1193 "duration_ms": 100,
1194 "duration_api_ms": 200,
1195 "num_turns": 1,
1196 "session_id": "abc",
1197 "total_cost_usd": 0.01
1198 }"#;
1199 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1200 assert!(result.as_assistant().is_none());
1201 }
1202
1203 #[test]
1204 fn test_as_result() {
1205 let json = r#"{
1206 "type": "result",
1207 "subtype": "success",
1208 "is_error": false,
1209 "duration_ms": 100,
1210 "duration_api_ms": 200,
1211 "num_turns": 5,
1212 "session_id": "abc",
1213 "total_cost_usd": 0.05
1214 }"#;
1215 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1216
1217 let result = output.as_result();
1218 assert!(result.is_some());
1219 assert_eq!(result.unwrap().num_turns, 5);
1220 assert_eq!(result.unwrap().total_cost_usd, 0.05);
1221
1222 let assistant_json = r#"{
1224 "type": "assistant",
1225 "message": {
1226 "id": "msg_1",
1227 "role": "assistant",
1228 "model": "claude-3",
1229 "content": []
1230 },
1231 "session_id": "abc"
1232 }"#;
1233 let assistant: ClaudeOutput = serde_json::from_str(assistant_json).unwrap();
1234 assert!(assistant.as_result().is_none());
1235 }
1236
1237 #[test]
1238 fn test_as_system() {
1239 let json = r#"{
1240 "type": "system",
1241 "subtype": "init",
1242 "session_id": "abc",
1243 "model": "claude-3"
1244 }"#;
1245 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1246
1247 let system = output.as_system();
1248 assert!(system.is_some());
1249 assert!(system.unwrap().is_init());
1250
1251 let result_json = r#"{
1253 "type": "result",
1254 "subtype": "success",
1255 "is_error": false,
1256 "duration_ms": 100,
1257 "duration_api_ms": 200,
1258 "num_turns": 1,
1259 "session_id": "abc",
1260 "total_cost_usd": 0.01
1261 }"#;
1262 let result: ClaudeOutput = serde_json::from_str(result_json).unwrap();
1263 assert!(result.as_system().is_none());
1264 }
1265}