1use chrono::{DateTime, Utc};
38use serde::{Deserialize, Serialize};
39use serde_json::Value as JsonValue;
40use std::collections::HashMap;
41use std::fmt;
42
43use super::message::{ContentBlock, MessageContent};
44use super::metadata::EventMetadata;
45use super::progress::{ProgressData, ProgressEvent};
46use super::system::SystemEvent;
47use super::tool_result::ToolUseResult;
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(tag = "type", rename_all = "lowercase")]
73pub enum SessionEvent {
74 #[serde(rename = "user")]
90 User(UserEvent),
91
92 #[serde(rename = "assistant")]
109 Assistant(AssistantEvent),
110
111 #[serde(rename = "progress")]
126 Progress(ProgressEvent),
127
128 #[serde(rename = "system")]
143 System(SystemEvent),
144
145 #[serde(rename = "file-history-snapshot")]
157 FileSnapshot(FileHistorySnapshot),
158
159 #[serde(rename = "queue-operation")]
167 QueueOperation(QueueOperation),
168
169 #[serde(rename = "summary")]
177 Summary(SessionSummary),
178
179 #[serde(rename = "attachment")]
188 Attachment(RootAttachmentEvent),
189
190 #[serde(rename = "custom-title")]
197 CustomTitle(CustomTitleEvent),
198
199 #[serde(rename = "ai-title")]
205 AiTitle(AiTitleEvent),
206
207 #[serde(rename = "last-prompt")]
213 LastPrompt(LastPromptEvent),
214
215 #[serde(rename = "bridge-session")]
219 BridgeSession(BridgeSessionEvent),
220
221 #[serde(rename = "atis-latch")]
223 AtisLatch(AtisLatchEvent),
224
225 #[serde(rename = "mode")]
227 Mode(ModeEvent),
228
229 #[serde(rename = "permission-mode")]
231 PermissionMode(PermissionModeEvent),
232
233 #[serde(rename = "agent-name")]
235 AgentName(AgentNameEvent),
236
237 #[serde(rename = "file-history-delta")]
240 FileHistoryDelta(FileHistoryDeltaEvent),
241
242 #[serde(other)]
249 Unknown,
250}
251
252impl SessionEvent {
253 pub fn metadata(&self) -> Option<EventMetadata> {
255 match self {
256 Self::User(e) => Some(e.metadata.clone()),
257 Self::Assistant(e) => Some(e.metadata.clone()),
258 Self::Progress(e) => Some(e.metadata.clone()),
259 Self::System(e) => Some(e.metadata()),
260 Self::Attachment(e) => Some(e.metadata.clone()),
261 _ => None,
262 }
263 }
264
265 pub fn uuid(&self) -> Option<&str> {
267 match self {
268 Self::User(e) => Some(&e.metadata.uuid),
269 Self::Assistant(e) => Some(&e.metadata.uuid),
270 Self::Progress(e) => Some(&e.metadata.uuid),
271 Self::System(e) => e.uuid.as_deref(),
272 Self::FileSnapshot(e) => Some(&e.message_id),
273 Self::QueueOperation(e) => Some(&e.session_id),
274 Self::Summary(e) => Some(&e.session_id),
275 Self::Attachment(e) => Some(&e.metadata.uuid),
276 Self::CustomTitle(e) => Some(&e.session_id),
277 Self::AiTitle(e) => Some(&e.session_id),
278 Self::LastPrompt(e) => Some(&e.session_id),
279 Self::BridgeSession(e) => Some(&e.session_id),
280 Self::AtisLatch(e) => Some(&e.session_id),
281 Self::Mode(e) => Some(&e.session_id),
282 Self::PermissionMode(e) => Some(&e.session_id),
283 Self::AgentName(e) => Some(&e.session_id),
284 Self::FileHistoryDelta(e) => Some(&e.message_id),
285 Self::Unknown => None,
286 }
287 }
288
289 pub fn parent_uuid(&self) -> Option<&str> {
291 match self {
292 Self::User(e) => e.metadata.parent_uuid.as_deref(),
293 Self::Assistant(e) => e.metadata.parent_uuid.as_deref(),
294 Self::Progress(e) => e.metadata.parent_uuid.as_deref(),
295 Self::System(e) => e.parent_uuid.as_deref(),
296 Self::Attachment(e) => e.metadata.parent_uuid.as_deref(),
297 _ => None,
298 }
299 }
300
301 pub fn timestamp(&self) -> DateTime<Utc> {
309 match self {
310 Self::User(e) => e.metadata.timestamp,
311 Self::Assistant(e) => e.metadata.timestamp,
312 Self::Progress(e) => e.metadata.timestamp,
313 Self::System(e) => e.timestamp,
314 Self::FileSnapshot(e) => e.timestamp,
315 Self::QueueOperation(e) => e.timestamp,
316 Self::Summary(e) => e.timestamp,
317 Self::Attachment(e) => e.metadata.timestamp,
318 Self::FileHistoryDelta(e) => e.timestamp,
319 Self::CustomTitle(_)
320 | Self::AiTitle(_)
321 | Self::LastPrompt(_)
322 | Self::BridgeSession(_)
323 | Self::AtisLatch(_)
324 | Self::Mode(_)
325 | Self::PermissionMode(_)
326 | Self::AgentName(_)
327 | Self::Unknown => Utc::now(),
328 }
329 }
330
331 pub fn extract_text_content(&self) -> Option<String> {
333 match self {
334 Self::User(e) => e.extract_text_content(),
335 Self::Assistant(e) => e.extract_text_content(),
336 Self::Progress(e) => match &e.data {
337 ProgressData::AgentProgress(agent) => Some(agent.prompt.clone()),
338 ProgressData::BashProgress(bash) => Some(bash.full_output.clone()),
339 _ => None,
340 },
341 Self::System(e) => e.content.clone(),
342 _ => None,
343 }
344 }
345
346 pub fn extract_file_paths(&self) -> Vec<String> {
348 match self {
349 Self::User(e) => e.extract_file_paths(),
350 Self::Assistant(e) => e.extract_file_paths(),
351 Self::FileSnapshot(e) => e.snapshot.tracked_file_backups.keys().cloned().collect(),
352 _ => Vec::new(),
353 }
354 }
355
356 pub fn extract_tool_names(&self) -> Vec<String> {
358 match self {
359 Self::User(e) => e.extract_tool_names(),
360 Self::Assistant(e) => e.extract_tool_names(),
361 _ => Vec::new(),
362 }
363 }
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct OriginInfo {
376 pub kind: String,
378}
379
380#[derive(Debug, Clone, Serialize, Deserialize)]
386pub struct UserEvent {
387 #[serde(flatten)]
389 pub metadata: EventMetadata,
390
391 pub message: MessageContent,
393
394 #[serde(rename = "permissionMode")]
396 pub permission_mode: Option<String>,
397
398 #[serde(rename = "isMeta")]
401 pub is_meta: Option<bool>,
402
403 #[serde(rename = "isCompactSummary")]
407 pub is_compact_summary: Option<bool>,
408
409 pub origin: Option<OriginInfo>,
415
416 #[serde(
421 rename = "toolUseResult",
422 deserialize_with = "super::tool_result::deserialize_tool_use_result_lenient",
423 default
424 )]
425 pub tool_use_result: Option<ToolUseResult>,
426
427 #[serde(rename = "sourceToolAssistantUUID")]
429 pub source_tool_assistant_uuid: Option<String>,
430}
431
432impl UserEvent {
433 pub fn uuid(&self) -> &str {
435 &self.metadata.uuid
436 }
437
438 pub fn parent_uuid(&self) -> Option<&str> {
440 self.metadata.parent_uuid.as_deref()
441 }
442
443 pub fn timestamp(&self) -> DateTime<Utc> {
445 self.metadata.timestamp
446 }
447
448 pub fn extract_text_content(&self) -> Option<String> {
450 let texts: Vec<String> = self
451 .message
452 .content
453 .iter()
454 .filter_map(|block| block.as_text().map(std::string::ToString::to_string))
455 .collect();
456
457 if texts.is_empty() {
458 None
459 } else {
460 Some(texts.join("\n"))
461 }
462 }
463
464 pub fn extract_file_paths(&self) -> Vec<String> {
466 let mut paths = Vec::new();
467
468 if let Some(result) = &self.tool_use_result {
470 if let Some(path) = result.file_path() {
471 paths.push(path.to_string());
472 }
473 }
474
475 for block in &self.message.content {
477 if let ContentBlock::ToolResult(result) = block {
478 if let Some(path) = extract_path_from_json(&result.content) {
479 paths.push(path);
480 }
481 }
482 }
483
484 paths
485 }
486
487 pub fn extract_tool_names(&self) -> Vec<String> {
489 self.message
490 .content
491 .iter()
492 .filter_map(|block| {
493 if let ContentBlock::ToolResult(result) = block {
494 Some(format!("tool_result:{}", result.tool_use_id))
495 } else {
496 None
497 }
498 })
499 .collect()
500 }
501
502 #[must_use]
529 pub fn classify_turn(&self) -> UserTurnKind<'_> {
530 if self.metadata.user_type.as_deref() != Some("external") {
531 return UserTurnKind::HarnessNotification;
532 }
533 if self.is_meta == Some(true) {
534 return UserTurnKind::HarnessNotification;
535 }
536 if self.is_compact_summary == Some(true) {
537 return UserTurnKind::HarnessNotification;
538 }
539 if matches!(
540 self.origin.as_ref().map(|origin| origin.kind.as_str()),
541 Some("task-notification" | "peer")
542 ) {
543 return UserTurnKind::HarnessNotification;
544 }
545
546 let Some(text) = self.message.content.iter().find_map(ContentBlock::as_text) else {
547 return UserTurnKind::HarnessNotification;
548 };
549 let trimmed = text.trim_start();
550
551 if trimmed.starts_with("<command-name>") {
552 if let (Some(name), Some(args)) =
553 (extract_tag(trimmed, "command-name"), extract_tag(trimmed, "command-args"))
554 {
555 return UserTurnKind::SlashCommand { name, args };
556 }
557 }
558
559 const NOTIFICATION_PREFIXES: [&str; 5] = [
560 "<local-command-caveat>",
561 "<local-command-stdout>",
562 "<local-command-stderr>",
563 "<task-notification>",
564 "<system-reminder>",
565 ];
566 if NOTIFICATION_PREFIXES.iter().any(|prefix| trimmed.starts_with(prefix)) {
567 return UserTurnKind::HarnessNotification;
568 }
569
570 UserTurnKind::HumanPrompt(text)
571 }
572}
573
574#[derive(Debug, Clone, Copy, PartialEq, Eq)]
576pub enum UserTurnKind<'a> {
577 HumanPrompt(&'a str),
580 SlashCommand {
585 name: &'a str,
587 args: &'a str,
589 },
590 HarnessNotification,
595}
596
597fn extract_tag<'a>(text: &'a str, tag: &str) -> Option<&'a str> {
599 let open = format!("<{tag}>");
600 let close = format!("</{tag}>");
601 let start = text.find(&open)? + open.len();
602 let end = start + text[start..].find(&close)?;
603 Some(&text[start..end])
604}
605
606#[derive(Debug, Clone, Serialize, Deserialize)]
610pub struct AssistantEvent {
611 #[serde(flatten)]
613 pub metadata: EventMetadata,
614
615 pub message: AssistantMessage,
617
618 #[serde(rename = "requestId")]
620 pub request_id: Option<String>,
621}
622
623impl AssistantEvent {
624 pub fn uuid(&self) -> &str {
626 &self.metadata.uuid
627 }
628
629 pub fn parent_uuid(&self) -> Option<&str> {
631 self.metadata.parent_uuid.as_deref()
632 }
633
634 pub fn timestamp(&self) -> DateTime<Utc> {
636 self.metadata.timestamp
637 }
638
639 pub fn extract_text_content(&self) -> Option<String> {
641 let texts: Vec<String> = self
642 .message
643 .content
644 .iter()
645 .filter_map(|block| block.as_text().map(std::string::ToString::to_string))
646 .collect();
647
648 if texts.is_empty() {
649 None
650 } else {
651 Some(texts.join("\n"))
652 }
653 }
654
655 pub fn extract_file_paths(&self) -> Vec<String> {
657 self.message
658 .content
659 .iter()
660 .filter_map(|block| {
661 if let Some((_, name, input)) = block.as_tool_use() {
662 if matches!(name, "Read" | "Write" | "Edit") {
663 extract_path_from_json(input)
664 } else {
665 None
666 }
667 } else {
668 None
669 }
670 })
671 .collect()
672 }
673
674 pub fn extract_tool_names(&self) -> Vec<String> {
676 self.message
677 .content
678 .iter()
679 .filter_map(|block| {
680 if let Some((_, name, _)) = block.as_tool_use() {
681 Some(name.to_string())
682 } else {
683 None
684 }
685 })
686 .collect()
687 }
688}
689
690#[derive(Debug, Clone, Serialize, Deserialize)]
692pub struct AssistantMessage {
693 pub model: String,
695
696 pub id: String,
698
699 #[serde(rename = "type")]
701 pub message_type: String,
702
703 pub role: String,
705
706 pub content: Vec<ContentBlock>,
708
709 #[serde(rename = "stop_reason")]
711 pub stop_reason: Option<String>,
712
713 #[serde(rename = "stop_sequence")]
715 pub stop_sequence: Option<String>,
716
717 pub usage: Option<TokenUsage>,
719
720 #[serde(rename = "context_management")]
722 pub context_management: Option<JsonValue>,
723}
724
725#[derive(Debug, Clone, Default, Serialize, Deserialize)]
729pub struct TokenUsage {
730 #[serde(default)]
732 pub input_tokens: u64,
733
734 #[serde(default)]
736 pub output_tokens: u64,
737
738 #[serde(default)]
740 pub cache_creation_input_tokens: u64,
741
742 #[serde(default)]
744 pub cache_read_input_tokens: u64,
745
746 #[serde(default)]
748 pub cache_creation: Option<CacheCreation>,
749
750 #[serde(default)]
752 pub service_tier: Option<String>,
753}
754
755#[derive(Debug, Clone, Serialize, Deserialize)]
757pub struct CacheCreation {
758 #[serde(default)]
760 pub ephemeral_5m_input_tokens: u64,
761
762 #[serde(default)]
764 pub ephemeral_1h_input_tokens: u64,
765}
766
767#[derive(Debug, Clone, Serialize, Deserialize)]
771pub struct FileHistorySnapshot {
772 #[serde(rename = "messageId")]
774 pub message_id: String,
775
776 pub snapshot: Snapshot,
778
779 #[serde(rename = "isSnapshotUpdate")]
781 pub is_snapshot_update: bool,
782
783 pub timestamp: DateTime<Utc>,
785}
786
787#[derive(Debug, Clone, Serialize, Deserialize)]
789pub struct Snapshot {
790 #[serde(rename = "messageId")]
792 pub message_id: String,
793
794 #[serde(rename = "trackedFileBackups")]
796 pub tracked_file_backups: HashMap<String, String>,
797
798 pub timestamp: DateTime<Utc>,
800}
801
802#[derive(Debug, Clone, Serialize, Deserialize)]
806pub struct QueueOperation {
807 pub operation: String,
809
810 #[serde(rename = "sessionId")]
812 pub session_id: String,
813
814 pub timestamp: DateTime<Utc>,
816}
817
818#[derive(Debug, Clone, Serialize, Deserialize)]
822pub struct SessionSummary {
823 #[serde(rename = "sessionId")]
825 pub session_id: String,
826
827 pub summary: Option<String>,
829
830 pub stats: Option<JsonValue>,
832
833 pub timestamp: DateTime<Utc>,
835}
836
837#[derive(Debug, Clone, Serialize, Deserialize)]
853pub struct RootAttachmentEvent {
854 #[serde(flatten)]
856 pub metadata: EventMetadata,
857
858 pub attachment: crate::events::attachment::AttachmentType,
860}
861
862#[derive(Debug, Clone, Serialize, Deserialize)]
864pub struct CustomTitleEvent {
865 #[serde(rename = "sessionId")]
867 pub session_id: String,
868
869 #[serde(rename = "customTitle")]
871 pub custom_title: String,
872}
873
874#[derive(Debug, Clone, Serialize, Deserialize)]
876pub struct AiTitleEvent {
877 #[serde(rename = "sessionId")]
879 pub session_id: String,
880
881 #[serde(rename = "aiTitle")]
883 pub ai_title: String,
884}
885
886#[derive(Debug, Clone, Serialize, Deserialize)]
888pub struct LastPromptEvent {
889 #[serde(rename = "sessionId")]
891 pub session_id: String,
892
893 #[serde(rename = "lastPrompt")]
895 pub last_prompt: String,
896
897 #[serde(rename = "leafUuid")]
899 pub leaf_uuid: Option<String>,
900}
901
902#[derive(Debug, Clone, Serialize, Deserialize)]
904pub struct BridgeSessionEvent {
905 #[serde(rename = "sessionId")]
907 pub session_id: String,
908}
909
910#[derive(Debug, Clone, Serialize, Deserialize)]
912pub struct AtisLatchEvent {
913 #[serde(rename = "sessionId")]
915 pub session_id: String,
916
917 pub atis: Option<String>,
919}
920
921#[derive(Debug, Clone, Serialize, Deserialize)]
923pub struct ModeEvent {
924 #[serde(rename = "sessionId")]
926 pub session_id: String,
927
928 pub mode: String,
930}
931
932#[derive(Debug, Clone, Serialize, Deserialize)]
934pub struct PermissionModeEvent {
935 #[serde(rename = "sessionId")]
937 pub session_id: String,
938
939 #[serde(rename = "permissionMode")]
941 pub permission_mode: String,
942}
943
944#[derive(Debug, Clone, Serialize, Deserialize)]
946pub struct AgentNameEvent {
947 #[serde(rename = "sessionId")]
949 pub session_id: String,
950
951 #[serde(rename = "agentName")]
953 pub agent_name: String,
954}
955
956#[derive(Debug, Clone, Serialize, Deserialize)]
959pub struct FileHistoryDeltaEvent {
960 #[serde(rename = "messageId")]
962 pub message_id: String,
963
964 #[serde(rename = "snapshotMessageId")]
966 pub snapshot_message_id: Option<String>,
967
968 #[serde(rename = "trackingPath")]
970 pub tracking_path: String,
971
972 pub timestamp: DateTime<Utc>,
974}
975
976fn extract_path_from_json(value: &JsonValue) -> Option<String> {
978 value
979 .get("file_path")
980 .or_else(|| value.get("filePath"))
981 .and_then(|v| v.as_str())
982 .map(std::string::ToString::to_string)
983}
984
985impl fmt::Display for SessionEvent {
987 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
988 match self {
989 Self::User(e) => write!(
990 f,
991 "User[{}] at {}",
992 e.metadata.uuid,
993 e.metadata.timestamp.format("%Y-%m-%d %H:%M:%S")
994 ),
995 Self::Assistant(e) => write!(
996 f,
997 "Assistant[{}] {} at {}",
998 e.metadata.uuid,
999 e.message.model,
1000 e.metadata.timestamp.format("%Y-%m-%d %H:%M:%S")
1001 ),
1002 Self::Progress(e) => write!(
1003 f,
1004 "Progress[{}] {:?} at {}",
1005 e.metadata.uuid,
1006 e.data,
1007 e.metadata.timestamp.format("%Y-%m-%d %H:%M:%S")
1008 ),
1009 Self::System(e) => {
1010 if let Some(uuid) = &e.uuid {
1011 write!(
1012 f,
1013 "System[{}] {:?} at {}",
1014 uuid,
1015 e.subtype,
1016 e.timestamp.format("%Y-%m-%d %H:%M:%S")
1017 )
1018 } else {
1019 write!(
1020 f,
1021 "System {:?} at {}",
1022 e.subtype,
1023 e.timestamp.format("%Y-%m-%d %H:%M:%S")
1024 )
1025 }
1026 }
1027 Self::FileSnapshot(e) => {
1028 write!(
1029 f,
1030 "FileSnapshot[{}] {} files",
1031 e.message_id,
1032 e.snapshot.tracked_file_backups.len()
1033 )
1034 }
1035 Self::QueueOperation(e) => write!(
1036 f,
1037 "QueueOp[{}] {} at {}",
1038 e.session_id,
1039 e.operation,
1040 e.timestamp.format("%Y-%m-%d %H:%M:%S")
1041 ),
1042 Self::Summary(e) => write!(f, "Summary[{}]", e.session_id),
1043 Self::Attachment(e) => write!(
1044 f,
1045 "Attachment[{}] at {}",
1046 e.metadata.uuid,
1047 e.metadata.timestamp.format("%Y-%m-%d %H:%M:%S")
1048 ),
1049 Self::CustomTitle(e) => write!(f, "CustomTitle[{}] {:?}", e.session_id, e.custom_title),
1050 Self::AiTitle(e) => write!(f, "AiTitle[{}] {:?}", e.session_id, e.ai_title),
1051 Self::LastPrompt(e) => write!(f, "LastPrompt[{}] {:?}", e.session_id, e.last_prompt),
1052 Self::BridgeSession(e) => write!(f, "BridgeSession[{}]", e.session_id),
1053 Self::AtisLatch(e) => write!(f, "AtisLatch[{}]", e.session_id),
1054 Self::Mode(e) => write!(f, "Mode[{}] {}", e.session_id, e.mode),
1055 Self::PermissionMode(e) => {
1056 write!(f, "PermissionMode[{}] {}", e.session_id, e.permission_mode)
1057 }
1058 Self::AgentName(e) => write!(f, "AgentName[{}] {:?}", e.session_id, e.agent_name),
1059 Self::FileHistoryDelta(e) => {
1060 write!(f, "FileHistoryDelta[{}] {}", e.message_id, e.tracking_path)
1061 }
1062 Self::Unknown => write!(f, "Unknown event"),
1063 }
1064 }
1065}
1066
1067impl fmt::Display for ProgressData {
1068 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1069 match self {
1070 Self::BashProgress(data) => write!(f, "Bash({}s)", data.elapsed_time_seconds),
1071 Self::HookProgress(data) => write!(f, "Hook({})", data.hook_name),
1072 Self::AgentProgress(data) => write!(f, "Agent({})", data.agent_id),
1073 Self::QueryUpdate(data) => write!(f, "Query({})", data.query),
1074 Self::SearchResultsReceived(_) => write!(f, "SearchResults"),
1075 Self::WaitingForTask(data) => write!(f, "WaitingForTask({})", data.task_type),
1076 Self::Unknown => write!(f, "UnknownProgress"),
1077 }
1078 }
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083 use super::*;
1084
1085 #[test]
1086 fn test_parse_user_event() {
1087 let json = r#"{
1088 "type": "user",
1089 "uuid": "user-uuid",
1090 "parentUuid": null,
1091 "sessionId": "session-123",
1092 "timestamp": "2024-01-01T00:00:00Z",
1093 "isSidechain": false,
1094 "userType": "external",
1095 "cwd": "/test",
1096 "message": {
1097 "role": "user",
1098 "content": [
1099 {"type": "text", "text": "Hello"}
1100 ]
1101 }
1102 }"#;
1103
1104 let event: SessionEvent = serde_json::from_str(json).unwrap();
1105 assert!(matches!(event, SessionEvent::User(_)));
1106 assert_eq!(event.uuid(), Some("user-uuid"));
1107 }
1108
1109 #[test]
1110 fn test_parse_assistant_event() {
1111 let json = r#"{
1112 "type": "assistant",
1113 "uuid": "assistant-uuid",
1114 "parentUuid": "user-uuid",
1115 "sessionId": "session-123",
1116 "timestamp": "2024-01-01T00:00:00Z",
1117 "isSidechain": false,
1118 "cwd": "/test",
1119 "message": {
1120 "model": "claude-sonnet-4-5",
1121 "id": "msg_123",
1122 "type": "message",
1123 "role": "assistant",
1124 "content": [
1125 {"type": "text", "text": "Hello!"}
1126 ],
1127 "stop_reason": "end_turn"
1128 }
1129 }"#;
1130
1131 let event: SessionEvent = serde_json::from_str(json).unwrap();
1132 assert!(matches!(event, SessionEvent::Assistant(_)));
1133 }
1134
1135 #[test]
1136 fn test_parse_progress_event() {
1137 let json = r#"{
1138 "type": "progress",
1139 "uuid": "progress-uuid",
1140 "parentUuid": null,
1141 "sessionId": "session-123",
1142 "timestamp": "2024-01-01T00:00:00Z",
1143 "isSidechain": false,
1144 "cwd": "/test",
1145 "toolUseID": "tool-123",
1146 "data": {
1147 "type": "bash_progress",
1148 "output": "test",
1149 "fullOutput": "test",
1150 "elapsedTimeSeconds": 1,
1151 "totalLines": 1,
1152 "message": {},
1153 "normalizedMessages": []
1154 }
1155 }"#;
1156
1157 let event: SessionEvent = serde_json::from_str(json).unwrap();
1158 assert!(matches!(event, SessionEvent::Progress(_)));
1159 }
1160
1161 #[test]
1162 fn test_extract_text_content() {
1163 let json = r#"{
1164 "type": "user",
1165 "uuid": "user-uuid",
1166 "parentUuid": null,
1167 "sessionId": "session-123",
1168 "timestamp": "2024-01-01T00:00:00Z",
1169 "isSidechain": false,
1170 "userType": "external",
1171 "cwd": "/test",
1172 "message": {
1173 "role": "user",
1174 "content": [
1175 {"type": "text", "text": "First line"},
1176 {"type": "text", "text": "Second line"}
1177 ]
1178 }
1179 }"#;
1180
1181 let event: SessionEvent = serde_json::from_str(json).unwrap();
1182 let text = event.extract_text_content();
1183 assert_eq!(text, Some("First line\nSecond line".to_string()));
1184 }
1185
1186 #[test]
1193 fn test_parse_user_event_with_bare_string_content() {
1194 let json = r#"{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{"role":"user","content":"placeholder prompt text"},"uuid":"user-uuid","timestamp":"2026-09-05T23:53:25.920Z","permissionMode":"auto","userType":"external","entrypoint":"claude-desktop","cwd":"C:\\work","sessionId":"session-123","version":"2.1.258","gitBranch":"main"}"#;
1197
1198 let event: SessionEvent = serde_json::from_str(json).expect("bare string content must parse");
1199 let SessionEvent::User(user) = event else {
1200 panic!("expected user event");
1201 };
1202 assert_eq!(user.classify_turn(), UserTurnKind::HumanPrompt("placeholder prompt text"));
1203 }
1204
1205 #[test]
1206 fn test_classify_turn_skips_meta_turns() {
1207 let json = r#"{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{"role":"user","content":"<local-command-caveat>Caveat: placeholder</local-command-caveat>"},"isMeta":true,"uuid":"user-uuid","timestamp":"2026-09-06T00:14:48.155Z","userType":"external","cwd":"C:\\work","sessionId":"session-123","version":"2.1.258","gitBranch":"main"}"#;
1208
1209 let SessionEvent::User(user) = serde_json::from_str::<SessionEvent>(json).unwrap() else {
1210 panic!("expected user event");
1211 };
1212 assert_eq!(user.classify_turn(), UserTurnKind::HarnessNotification);
1213 }
1214
1215 #[test]
1216 fn test_classify_turn_recognizes_slash_command_with_args() {
1217 let json = r#"{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{"role":"user","content":"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>placeholder-model</command-args>"},"uuid":"user-uuid","timestamp":"2026-08-28T17:02:33.492Z","userType":"external","cwd":"C:\\work","sessionId":"session-123","version":"2.1.246","gitBranch":"main"}"#;
1219
1220 let SessionEvent::User(user) = serde_json::from_str::<SessionEvent>(json).unwrap() else {
1221 panic!("expected user event");
1222 };
1223 assert_eq!(
1224 user.classify_turn(),
1225 UserTurnKind::SlashCommand { name: "/model", args: "placeholder-model" }
1226 );
1227 }
1228
1229 #[test]
1230 fn test_classify_turn_recognizes_slash_command_without_args() {
1231 let json = r#"{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{"role":"user","content":"<command-name>/compact</command-name>\n <command-message>compact</command-message>\n <command-args></command-args>"},"uuid":"user-uuid","timestamp":"2026-08-28T17:02:33.492Z","userType":"external","cwd":"C:\\work","sessionId":"session-123","version":"2.1.246","gitBranch":"main"}"#;
1233
1234 let SessionEvent::User(user) = serde_json::from_str::<SessionEvent>(json).unwrap() else {
1235 panic!("expected user event");
1236 };
1237 assert_eq!(user.classify_turn(), UserTurnKind::SlashCommand { name: "/compact", args: "" });
1238 }
1239
1240 #[test]
1241 fn test_classify_turn_skips_local_command_stdout_and_stderr() {
1242 for content in [
1243 "<local-command-stdout>Set model to placeholder</local-command-stdout>",
1244 "<local-command-stderr>placeholder error</local-command-stderr>",
1245 ] {
1246 let json = format!(
1247 r#"{{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{{"role":"user","content":"{content}"}},"uuid":"user-uuid","timestamp":"2026-08-28T17:02:33.492Z","userType":"external","cwd":"C:\\work","sessionId":"session-123","version":"2.1.246","gitBranch":"main"}}"#
1248 );
1249 let SessionEvent::User(user) = serde_json::from_str::<SessionEvent>(&json).unwrap() else {
1250 panic!("expected user event");
1251 };
1252 assert_eq!(
1253 user.classify_turn(),
1254 UserTurnKind::HarnessNotification,
1255 "must skip: {content}"
1256 );
1257 }
1258 }
1259
1260 #[test]
1261 fn test_classify_turn_skips_task_notification_by_content_and_by_origin() {
1262 let json = r#"{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{"role":"user","content":"<task-notification>\n<task-id>placeholder</task-id>\n<status>completed</status>\n</task-notification>"},"uuid":"user-uuid","timestamp":"2026-09-22T00:00:00.000Z","userType":"external","cwd":"C:\\work","sessionId":"session-123","version":"2.1.258","gitBranch":"main"}"#;
1264 let SessionEvent::User(user) = serde_json::from_str::<SessionEvent>(json).unwrap() else {
1265 panic!("expected user event");
1266 };
1267 assert_eq!(user.classify_turn(), UserTurnKind::HarnessNotification);
1268
1269 let json = r#"{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{"role":"user","content":"placeholder result text, not a real prompt"},"uuid":"user-uuid","timestamp":"2026-09-22T00:00:00.000Z","userType":"external","origin":{"kind":"task-notification"},"cwd":"C:\\work","sessionId":"session-123","version":"2.1.258","gitBranch":"main"}"#;
1271 let SessionEvent::User(user) = serde_json::from_str::<SessionEvent>(json).unwrap() else {
1272 panic!("expected user event");
1273 };
1274 assert_eq!(user.classify_turn(), UserTurnKind::HarnessNotification);
1275 }
1276
1277 #[test]
1278 fn test_classify_turn_skips_peer_cross_session_message() {
1279 let json = r#"{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{"role":"user","content":"Another Claude session sent a message:\n<cross-session-message from=\"placeholder\">placeholder body</cross-session-message>"},"isMeta":true,"origin":{"kind":"peer"},"uuid":"user-uuid","timestamp":"2026-08-28T17:02:33.492Z","userType":"external","cwd":"C:\\work","sessionId":"session-123","version":"2.1.246","gitBranch":"main"}"#;
1281 let SessionEvent::User(user) = serde_json::from_str::<SessionEvent>(json).unwrap() else {
1282 panic!("expected user event");
1283 };
1284 assert_eq!(user.classify_turn(), UserTurnKind::HarnessNotification);
1285 }
1286
1287 #[test]
1288 fn test_classify_turn_skips_compact_summary() {
1289 let json = r#"{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{"role":"user","content":"This session is being continued from a previous conversation that ran out of context. Summary: placeholder"},"isCompactSummary":true,"isVisibleInTranscriptOnly":true,"uuid":"user-uuid","timestamp":"2026-09-10T00:00:00.000Z","userType":"external","cwd":"C:\\work","sessionId":"session-123","version":"2.1.258","gitBranch":"main"}"#;
1294 let SessionEvent::User(user) = serde_json::from_str::<SessionEvent>(json).unwrap() else {
1295 panic!("expected user event");
1296 };
1297 assert_eq!(user.classify_turn(), UserTurnKind::HarnessNotification);
1298 }
1299
1300 #[test]
1301 fn test_classify_turn_keeps_interruption_marker_as_human_prompt() {
1302 let json = r#"{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{"role":"user","content":[{"type":"text","text":"[Request interrupted by user]"}]},"uuid":"user-uuid","timestamp":"2026-08-28T17:02:27.169Z","userType":"external","cwd":"C:\\work","sessionId":"session-123","version":"2.1.246","gitBranch":"main"}"#;
1306 let SessionEvent::User(user) = serde_json::from_str::<SessionEvent>(json).unwrap() else {
1307 panic!("expected user event");
1308 };
1309 assert_eq!(user.classify_turn(), UserTurnKind::HumanPrompt("[Request interrupted by user]"));
1310 }
1311
1312 #[test]
1313 fn test_classify_turn_skips_tool_result_only_turn() {
1314 let json = r#"{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{"role":"user","content":[{"tool_use_id":"tool-use-id","type":"tool_result","content":"<system-reminder>placeholder warning</system-reminder>"}]},"uuid":"user-uuid","timestamp":"2026-08-30T23:51:17.045Z","toolUseResult":{"type":"text","file":{"filePath":"C:\\work\\out.txt","content":"","numLines":1,"startLine":1,"totalLines":1}},"sourceToolAssistantUUID":"assistant-uuid","userType":"external","cwd":"C:\\work","sessionId":"session-123","version":"2.1.246","gitBranch":"main"}"#;
1317
1318 let event: SessionEvent =
1319 serde_json::from_str(json).expect("mismatched toolUseResult shape must not fail the event");
1320 let SessionEvent::User(user) = event else {
1321 panic!("expected user event");
1322 };
1323 assert_eq!(user.classify_turn(), UserTurnKind::HarnessNotification);
1324 assert!(user.tool_use_result.is_none());
1325 }
1326
1327 #[test]
1328 fn test_classify_turn_skips_internal_user_type() {
1329 let json = r#"{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{"role":"user","content":"placeholder"},"uuid":"user-uuid","timestamp":"2026-08-28T17:02:33.492Z","userType":"internal","cwd":"C:\\work","sessionId":"session-123","version":"2.1.246","gitBranch":"main"}"#;
1330
1331 let SessionEvent::User(user) = serde_json::from_str::<SessionEvent>(json).unwrap() else {
1332 panic!("expected user event");
1333 };
1334 assert_eq!(user.classify_turn(), UserTurnKind::HarnessNotification);
1335 }
1336
1337 use crate::events::attachment::AttachmentType;
1338
1339 #[test]
1340 fn test_parse_root_attachment_event() {
1341 let json = r#"{"parentUuid":null,"isSidechain":false,"attachment":{"type":"hook_success","hookName":"SessionStart:startup","hookEvent":"SessionStart","output":"placeholder"},"type":"attachment","uuid":"attachment-uuid","timestamp":"2026-09-05T23:53:25.318Z","userType":"external","cwd":"C:\\work","sessionId":"session-123","version":"2.1.258","gitBranch":"main"}"#;
1342
1343 let event: SessionEvent = serde_json::from_str(json).expect("root attachment event must parse");
1344 let SessionEvent::Attachment(attachment) = event else {
1345 panic!("expected attachment event");
1346 };
1347 assert_eq!(attachment.metadata.uuid, "attachment-uuid");
1348 assert!(matches!(attachment.attachment, AttachmentType::HookSuccess(_)));
1349 }
1350
1351 #[test]
1352 fn test_parse_custom_title_last_prompt_ai_title() {
1353 let custom_title: SessionEvent =
1354 serde_json::from_str(r#"{"type":"custom-title","customTitle":"placeholder title","sessionId":"session-123"}"#)
1355 .unwrap();
1356 assert!(matches!(custom_title, SessionEvent::CustomTitle(ref e) if e.custom_title == "placeholder title"));
1357
1358 let ai_title: SessionEvent = serde_json::from_str(
1359 r#"{"type":"ai-title","aiTitle":"placeholder ai title","sessionId":"session-123"}"#,
1360 )
1361 .unwrap();
1362 assert!(matches!(ai_title, SessionEvent::AiTitle(ref e) if e.ai_title == "placeholder ai title"));
1363
1364 let last_prompt: SessionEvent = serde_json::from_str(
1365 r#"{"type":"last-prompt","lastPrompt":"placeholder last prompt","leafUuid":"leaf-uuid","sessionId":"session-123"}"#,
1366 )
1367 .unwrap();
1368 assert!(
1369 matches!(last_prompt, SessionEvent::LastPrompt(ref e) if e.last_prompt == "placeholder last prompt")
1370 );
1371 }
1372
1373 #[test]
1374 fn test_parse_harness_internal_markers_never_fail() {
1375 let lines = [
1379 r#"{"type":"bridge-session","sessionId":"session-123","bridgeSessionId":"cse_placeholder","lastSequenceNum":0}"#,
1380 r#"{"type":"atis-latch","atis":"","sessionId":"session-123"}"#,
1381 r#"{"type":"mode","mode":"normal","sessionId":"session-123"}"#,
1382 r#"{"type":"permission-mode","permissionMode":"auto","sessionId":"session-123"}"#,
1383 r#"{"type":"agent-name","agentName":"placeholder agent","sessionId":"session-123"}"#,
1384 r#"{"type":"file-history-delta","messageId":"message-id","snapshotMessageId":"snapshot-id","trackingPath":"src/lib.rs","backup":{"backupFileName":"placeholder","version":1},"timestamp":"2026-09-16T16:23:51.749Z"}"#,
1385 ];
1386 for line in lines {
1387 let event: SessionEvent =
1388 serde_json::from_str(line).unwrap_or_else(|error| panic!("must parse {line}: {error}"));
1389 assert!(
1390 !matches!(event, SessionEvent::Unknown),
1391 "must not fall back to Unknown: {line}"
1392 );
1393 }
1394 }
1395
1396 #[test]
1397 fn test_unrecognized_root_type_falls_back_to_unknown_without_failing() {
1398 let json = r#"{"type":"some-future-event-type","sessionId":"session-123","payload":{"nested":true}}"#;
1399 let event: SessionEvent = serde_json::from_str(json).expect("unknown root type must not fail parsing");
1400 assert!(matches!(event, SessionEvent::Unknown));
1401 }
1402}