1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5pub const ATTR_SOURCE_SCHEMA_VERSION: &str = "source.schema_version";
7pub const ATTR_SOURCE_RAW_TYPE: &str = "source.raw_type";
9pub const ATTR_SEMANTIC_GROUP_ID: &str = "semantic.group_id";
11pub const ATTR_SEMANTIC_CALL_ID: &str = "semantic.call_id";
13pub const ATTR_SEMANTIC_TOOL_KIND: &str = "semantic.tool_kind";
15pub const ATTR_LEGACY_CALL_ID: &str = "call_id";
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Session {
21 pub version: String,
23 pub session_id: String,
25 pub agent: Agent,
27 pub context: SessionContext,
29 pub events: Vec<Event>,
31 pub stats: Stats,
33}
34
35#[derive(Default)]
36struct StatsAcc {
37 message_count: u64,
38 user_message_count: u64,
39 tool_call_count: u64,
40 task_ids: std::collections::HashSet<String>,
41 total_input_tokens: u64,
42 total_output_tokens: u64,
43 changed_files: std::collections::HashSet<String>,
44 lines_added: u64,
45 lines_removed: u64,
46}
47
48impl StatsAcc {
49 fn process(mut self, event: &Event) -> Self {
50 match &event.event_type {
51 EventType::UserMessage => {
52 self.message_count += 1;
53 self.user_message_count += 1;
54 }
55 EventType::AgentMessage => self.message_count += 1,
56 EventType::TaskEnd { summary } => {
57 if summary
58 .as_deref()
59 .map(str::trim)
60 .is_some_and(|text| !text.is_empty())
61 {
62 self.message_count += 1;
63 }
64 }
65 EventType::ToolCall { .. }
66 | EventType::FileRead { .. }
67 | EventType::CodeSearch { .. }
68 | EventType::FileSearch { .. } => self.tool_call_count += 1,
69 EventType::FileEdit { path, diff } => {
70 self.changed_files.insert(path.clone());
71 if let Some(d) = diff {
72 for line in d.lines() {
73 if line.starts_with('+') && !line.starts_with("+++") {
74 self.lines_added += 1;
75 } else if line.starts_with('-') && !line.starts_with("---") {
76 self.lines_removed += 1;
77 }
78 }
79 }
80 }
81 EventType::FileCreate { path } | EventType::FileDelete { path } => {
82 self.changed_files.insert(path.clone());
83 }
84 _ => {}
85 }
86 if let Some(ref tid) = event.task_id {
87 self.task_ids.insert(tid.clone());
88 }
89 if let Some(v) = event.attributes.get("input_tokens") {
90 self.total_input_tokens += v.as_u64().unwrap_or(0);
91 }
92 if let Some(v) = event.attributes.get("output_tokens") {
93 self.total_output_tokens += v.as_u64().unwrap_or(0);
94 }
95 self
96 }
97
98 fn into_stats(self, events: &[Event]) -> Stats {
99 let duration_seconds = if let (Some(first), Some(last)) = (events.first(), events.last()) {
100 (last.timestamp - first.timestamp).num_seconds().max(0) as u64
101 } else {
102 0
103 };
104
105 Stats {
106 event_count: events.len() as u64,
107 message_count: self.message_count,
108 tool_call_count: self.tool_call_count,
109 task_count: self.task_ids.len() as u64,
110 duration_seconds,
111 total_input_tokens: self.total_input_tokens,
112 total_output_tokens: self.total_output_tokens,
113 user_message_count: self.user_message_count,
114 files_changed: self.changed_files.len() as u64,
115 lines_added: self.lines_added,
116 lines_removed: self.lines_removed,
117 }
118 }
119}
120
121impl Session {
122 pub const CURRENT_VERSION: &'static str = "hail-1.0.0";
123
124 pub fn new(session_id: String, agent: Agent) -> Self {
125 Self {
126 version: Self::CURRENT_VERSION.to_string(),
127 session_id,
128 agent,
129 context: SessionContext::default(),
130 events: Vec::new(),
131 stats: Stats::default(),
132 }
133 }
134
135 pub fn to_jsonl(&self) -> Result<String, crate::jsonl::JsonlError> {
137 crate::jsonl::to_jsonl_string(self)
138 }
139
140 pub fn from_jsonl(s: &str) -> Result<Self, crate::jsonl::JsonlError> {
142 crate::jsonl::from_jsonl_str(s)
143 }
144
145 pub fn recompute_stats(&mut self) {
147 let acc = self
148 .events
149 .iter()
150 .fold(StatsAcc::default(), StatsAcc::process);
151 self.stats = acc.into_stats(&self.events);
152 }
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct Agent {
158 pub provider: String,
160 pub model: String,
162 pub tool: String,
164 #[serde(skip_serializing_if = "Option::is_none")]
166 pub tool_version: Option<String>,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct SessionContext {
172 #[serde(skip_serializing_if = "Option::is_none")]
173 pub title: Option<String>,
174 #[serde(skip_serializing_if = "Option::is_none")]
175 pub description: Option<String>,
176 #[serde(default)]
177 pub tags: Vec<String>,
178 pub created_at: DateTime<Utc>,
179 pub updated_at: DateTime<Utc>,
180 #[serde(default, skip_serializing_if = "Vec::is_empty")]
181 pub related_session_ids: Vec<String>,
182 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
183 pub attributes: HashMap<String, serde_json::Value>,
184}
185
186impl Default for SessionContext {
187 fn default() -> Self {
188 let now = Utc::now();
189 Self {
190 title: None,
191 description: None,
192 tags: Vec::new(),
193 created_at: now,
194 updated_at: now,
195 related_session_ids: Vec::new(),
196 attributes: HashMap::new(),
197 }
198 }
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct Event {
204 pub event_id: String,
206 pub timestamp: DateTime<Utc>,
208 pub event_type: EventType,
210 #[serde(skip_serializing_if = "Option::is_none")]
212 pub task_id: Option<String>,
213 pub content: Content,
215 #[serde(skip_serializing_if = "Option::is_none")]
217 pub duration_ms: Option<u64>,
218 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
220 pub attributes: HashMap<String, serde_json::Value>,
221}
222
223impl Event {
224 pub fn attr_str(&self, key: &str) -> Option<&str> {
226 self.attributes
227 .get(key)
228 .and_then(|value| value.as_str())
229 .map(str::trim)
230 .filter(|value| !value.is_empty())
231 }
232
233 pub fn source_schema_version(&self) -> Option<&str> {
235 self.attr_str(ATTR_SOURCE_SCHEMA_VERSION)
236 }
237
238 pub fn source_raw_type(&self) -> Option<&str> {
240 self.attr_str(ATTR_SOURCE_RAW_TYPE)
241 }
242
243 pub fn semantic_group_id(&self) -> Option<&str> {
245 self.attr_str(ATTR_SEMANTIC_GROUP_ID)
246 }
247
248 pub fn semantic_tool_kind(&self) -> Option<&str> {
250 self.attr_str(ATTR_SEMANTIC_TOOL_KIND)
251 }
252
253 pub fn semantic_call_id(&self) -> Option<&str> {
260 if let Some(call_id) = self.attr_str(ATTR_SEMANTIC_CALL_ID) {
261 return Some(call_id);
262 }
263
264 if let EventType::ToolResult {
265 call_id: Some(call_id),
266 ..
267 } = &self.event_type
268 {
269 let trimmed = call_id.trim();
270 if !trimmed.is_empty() {
271 return Some(trimmed);
272 }
273 }
274
275 self.attr_str(ATTR_LEGACY_CALL_ID)
276 }
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
281#[serde(tag = "type", content = "data")]
282#[non_exhaustive]
283pub enum EventType {
284 UserMessage,
286 AgentMessage,
287 SystemMessage,
288
289 Thinking,
291
292 ToolCall {
294 name: String,
295 },
296 ToolResult {
297 name: String,
298 is_error: bool,
299 #[serde(skip_serializing_if = "Option::is_none")]
300 call_id: Option<String>,
301 },
302 FileRead {
303 path: String,
304 },
305 CodeSearch {
306 query: String,
307 },
308 FileSearch {
309 pattern: String,
310 },
311 FileEdit {
312 path: String,
313 #[serde(skip_serializing_if = "Option::is_none")]
314 diff: Option<String>,
315 },
316 FileCreate {
317 path: String,
318 },
319 FileDelete {
320 path: String,
321 },
322 ShellCommand {
323 command: String,
324 #[serde(skip_serializing_if = "Option::is_none")]
325 exit_code: Option<i32>,
326 },
327
328 ImageGenerate {
330 prompt: String,
331 },
332 VideoGenerate {
333 prompt: String,
334 },
335 AudioGenerate {
336 prompt: String,
337 },
338
339 WebSearch {
341 query: String,
342 },
343 WebFetch {
344 url: String,
345 },
346
347 TaskStart {
349 #[serde(skip_serializing_if = "Option::is_none")]
350 title: Option<String>,
351 },
352 TaskEnd {
353 #[serde(skip_serializing_if = "Option::is_none")]
354 summary: Option<String>,
355 },
356
357 Custom {
359 kind: String,
360 },
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct Content {
366 pub blocks: Vec<ContentBlock>,
367}
368
369impl Content {
370 pub fn empty() -> Self {
371 Self { blocks: Vec::new() }
372 }
373
374 pub fn text(text: impl Into<String>) -> Self {
375 Self {
376 blocks: vec![ContentBlock::Text { text: text.into() }],
377 }
378 }
379
380 pub fn code(code: impl Into<String>, language: Option<String>) -> Self {
381 Self {
382 blocks: vec![ContentBlock::Code {
383 code: code.into(),
384 language,
385 start_line: None,
386 }],
387 }
388 }
389}
390
391#[derive(Debug, Clone, Serialize, Deserialize)]
393#[serde(tag = "type")]
394#[non_exhaustive]
395pub enum ContentBlock {
396 Text {
397 text: String,
398 },
399 Code {
400 code: String,
401 #[serde(skip_serializing_if = "Option::is_none")]
402 language: Option<String>,
403 #[serde(skip_serializing_if = "Option::is_none")]
404 start_line: Option<u32>,
405 },
406 Image {
407 url: String,
408 #[serde(skip_serializing_if = "Option::is_none")]
409 alt: Option<String>,
410 mime: String,
411 },
412 Video {
413 url: String,
414 mime: String,
415 },
416 Audio {
417 url: String,
418 mime: String,
419 },
420 File {
421 path: String,
422 #[serde(skip_serializing_if = "Option::is_none")]
423 content: Option<String>,
424 },
425 Json {
426 data: serde_json::Value,
427 },
428 Reference {
429 uri: String,
430 media_type: String,
431 },
432}
433
434#[derive(Debug, Clone, Default, Serialize, Deserialize)]
436pub struct Stats {
437 pub event_count: u64,
438 pub message_count: u64,
439 pub tool_call_count: u64,
440 pub task_count: u64,
441 pub duration_seconds: u64,
442 #[serde(default)]
443 pub total_input_tokens: u64,
444 #[serde(default)]
445 pub total_output_tokens: u64,
446 #[serde(default)]
447 pub user_message_count: u64,
448 #[serde(default)]
449 pub files_changed: u64,
450 #[serde(default)]
451 pub lines_added: u64,
452 #[serde(default)]
453 pub lines_removed: u64,
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 #[test]
461 fn test_session_roundtrip() {
462 let session = Session::new(
463 "test-session-id".to_string(),
464 Agent {
465 provider: "anthropic".to_string(),
466 model: "claude-opus-4-6".to_string(),
467 tool: "claude-code".to_string(),
468 tool_version: Some("1.0.0".to_string()),
469 },
470 );
471
472 let json = serde_json::to_string_pretty(&session).unwrap();
473 let parsed: Session = serde_json::from_str(&json).unwrap();
474 assert_eq!(parsed.version, "hail-1.0.0");
475 assert_eq!(parsed.session_id, "test-session-id");
476 assert_eq!(parsed.agent.provider, "anthropic");
477 }
478
479 #[test]
480 fn test_event_type_serialization() {
481 let event_type = EventType::ToolCall {
482 name: "Read".to_string(),
483 };
484 let json = serde_json::to_string(&event_type).unwrap();
485 assert!(json.contains("ToolCall"));
486 assert!(json.contains("Read"));
487
488 let parsed: EventType = serde_json::from_str(&json).unwrap();
489 match parsed {
490 EventType::ToolCall { name } => assert_eq!(name, "Read"),
491 _ => panic!("Wrong variant"),
492 }
493 }
494
495 #[test]
496 fn test_content_block_variants() {
497 let blocks = vec![
498 ContentBlock::Text {
499 text: "Hello".to_string(),
500 },
501 ContentBlock::Code {
502 code: "fn main() {}".to_string(),
503 language: Some("rust".to_string()),
504 start_line: None,
505 },
506 ContentBlock::Image {
507 url: "https://example.com/img.png".to_string(),
508 alt: Some("Screenshot".to_string()),
509 mime: "image/png".to_string(),
510 },
511 ];
512
513 let content = Content { blocks };
514 let json = serde_json::to_string_pretty(&content).unwrap();
515 let parsed: Content = serde_json::from_str(&json).unwrap();
516 assert_eq!(parsed.blocks.len(), 3);
517 }
518
519 #[test]
520 fn test_recompute_stats() {
521 let mut session = Session::new(
522 "test".to_string(),
523 Agent {
524 provider: "anthropic".to_string(),
525 model: "claude-opus-4-6".to_string(),
526 tool: "claude-code".to_string(),
527 tool_version: None,
528 },
529 );
530
531 session.events.push(Event {
532 event_id: "e1".to_string(),
533 timestamp: Utc::now(),
534 event_type: EventType::UserMessage,
535 task_id: Some("t1".to_string()),
536 content: Content::text("hello"),
537 duration_ms: None,
538 attributes: HashMap::new(),
539 });
540
541 session.events.push(Event {
542 event_id: "e2".to_string(),
543 timestamp: Utc::now(),
544 event_type: EventType::ToolCall {
545 name: "Read".to_string(),
546 },
547 task_id: Some("t1".to_string()),
548 content: Content::empty(),
549 duration_ms: Some(100),
550 attributes: HashMap::new(),
551 });
552
553 session.events.push(Event {
554 event_id: "e3".to_string(),
555 timestamp: Utc::now(),
556 event_type: EventType::AgentMessage,
557 task_id: Some("t2".to_string()),
558 content: Content::text("done"),
559 duration_ms: None,
560 attributes: HashMap::new(),
561 });
562
563 session.recompute_stats();
564 assert_eq!(session.stats.event_count, 3);
565 assert_eq!(session.stats.message_count, 2);
566 assert_eq!(session.stats.tool_call_count, 1);
567 assert_eq!(session.stats.task_count, 2);
568 }
569
570 #[test]
571 fn test_recompute_stats_counts_task_end_summary_as_message() {
572 let mut session = Session::new(
573 "test-task-end-summary".to_string(),
574 Agent {
575 provider: "anthropic".to_string(),
576 model: "claude-opus-4-6".to_string(),
577 tool: "claude-code".to_string(),
578 tool_version: None,
579 },
580 );
581
582 let ts = Utc::now();
583 session.events.push(Event {
584 event_id: "u1".to_string(),
585 timestamp: ts,
586 event_type: EventType::UserMessage,
587 task_id: Some("t1".to_string()),
588 content: Content::text("do this"),
589 duration_ms: None,
590 attributes: HashMap::new(),
591 });
592 session.events.push(Event {
593 event_id: "t1-end".to_string(),
594 timestamp: ts,
595 event_type: EventType::TaskEnd {
596 summary: Some("finished successfully".to_string()),
597 },
598 task_id: Some("t1".to_string()),
599 content: Content::text("finished successfully"),
600 duration_ms: None,
601 attributes: HashMap::new(),
602 });
603
604 session.recompute_stats();
605 assert_eq!(session.stats.message_count, 2);
606 assert_eq!(session.stats.user_message_count, 1);
607 }
608
609 #[test]
610 fn test_file_read_serialization() {
611 let et = EventType::FileRead {
612 path: "/tmp/test.rs".to_string(),
613 };
614 let json = serde_json::to_string(&et).unwrap();
615 assert!(json.contains("FileRead"));
616 let parsed: EventType = serde_json::from_str(&json).unwrap();
617 match parsed {
618 EventType::FileRead { path } => assert_eq!(path, "/tmp/test.rs"),
619 _ => panic!("Expected FileRead"),
620 }
621 }
622
623 #[test]
624 fn test_code_search_serialization() {
625 let et = EventType::CodeSearch {
626 query: "fn main".to_string(),
627 };
628 let json = serde_json::to_string(&et).unwrap();
629 assert!(json.contains("CodeSearch"));
630 let parsed: EventType = serde_json::from_str(&json).unwrap();
631 match parsed {
632 EventType::CodeSearch { query } => assert_eq!(query, "fn main"),
633 _ => panic!("Expected CodeSearch"),
634 }
635 }
636
637 #[test]
638 fn test_file_search_serialization() {
639 let et = EventType::FileSearch {
640 pattern: "**/*.rs".to_string(),
641 };
642 let json = serde_json::to_string(&et).unwrap();
643 assert!(json.contains("FileSearch"));
644 let parsed: EventType = serde_json::from_str(&json).unwrap();
645 match parsed {
646 EventType::FileSearch { pattern } => assert_eq!(pattern, "**/*.rs"),
647 _ => panic!("Expected FileSearch"),
648 }
649 }
650
651 #[test]
652 fn test_tool_result_with_call_id() {
653 let et = EventType::ToolResult {
654 name: "Read".to_string(),
655 is_error: false,
656 call_id: Some("call-123".to_string()),
657 };
658 let json = serde_json::to_string(&et).unwrap();
659 assert!(json.contains("call_id"));
660 assert!(json.contains("call-123"));
661 let parsed: EventType = serde_json::from_str(&json).unwrap();
662 match parsed {
663 EventType::ToolResult {
664 name,
665 is_error,
666 call_id,
667 } => {
668 assert_eq!(name, "Read");
669 assert!(!is_error);
670 assert_eq!(call_id, Some("call-123".to_string()));
671 }
672 _ => panic!("Expected ToolResult"),
673 }
674 }
675
676 #[test]
677 fn test_tool_result_without_call_id() {
678 let et = EventType::ToolResult {
679 name: "Bash".to_string(),
680 is_error: true,
681 call_id: None,
682 };
683 let json = serde_json::to_string(&et).unwrap();
684 assert!(!json.contains("call_id"));
685 let parsed: EventType = serde_json::from_str(&json).unwrap();
686 match parsed {
687 EventType::ToolResult { call_id, .. } => assert_eq!(call_id, None),
688 _ => panic!("Expected ToolResult"),
689 }
690 }
691
692 #[test]
693 fn test_recompute_stats_new_tool_types() {
694 let mut session = Session::new(
695 "test2".to_string(),
696 Agent {
697 provider: "anthropic".to_string(),
698 model: "claude-opus-4-6".to_string(),
699 tool: "claude-code".to_string(),
700 tool_version: None,
701 },
702 );
703
704 let ts = Utc::now();
705 session.events.push(Event {
706 event_id: "e1".to_string(),
707 timestamp: ts,
708 event_type: EventType::FileRead {
709 path: "/tmp/a.rs".to_string(),
710 },
711 task_id: None,
712 content: Content::empty(),
713 duration_ms: None,
714 attributes: HashMap::new(),
715 });
716 session.events.push(Event {
717 event_id: "e2".to_string(),
718 timestamp: ts,
719 event_type: EventType::CodeSearch {
720 query: "fn main".to_string(),
721 },
722 task_id: None,
723 content: Content::empty(),
724 duration_ms: None,
725 attributes: HashMap::new(),
726 });
727 session.events.push(Event {
728 event_id: "e3".to_string(),
729 timestamp: ts,
730 event_type: EventType::FileSearch {
731 pattern: "*.rs".to_string(),
732 },
733 task_id: None,
734 content: Content::empty(),
735 duration_ms: None,
736 attributes: HashMap::new(),
737 });
738 session.events.push(Event {
739 event_id: "e4".to_string(),
740 timestamp: ts,
741 event_type: EventType::ToolCall {
742 name: "Task".to_string(),
743 },
744 task_id: None,
745 content: Content::empty(),
746 duration_ms: None,
747 attributes: HashMap::new(),
748 });
749
750 session.recompute_stats();
751 assert_eq!(session.stats.tool_call_count, 4);
752 }
753
754 #[test]
755 fn test_event_attr_helpers_normalize_empty_strings() {
756 let mut attrs = HashMap::new();
757 attrs.insert(
758 ATTR_SOURCE_SCHEMA_VERSION.to_string(),
759 serde_json::Value::String(" ".to_string()),
760 );
761 attrs.insert(
762 ATTR_SOURCE_RAW_TYPE.to_string(),
763 serde_json::Value::String("event_msg".to_string()),
764 );
765 let event = Event {
766 event_id: "e1".to_string(),
767 timestamp: Utc::now(),
768 event_type: EventType::SystemMessage,
769 task_id: None,
770 content: Content::empty(),
771 duration_ms: None,
772 attributes: attrs,
773 };
774 assert_eq!(event.source_schema_version(), None);
775 assert_eq!(event.source_raw_type(), Some("event_msg"));
776 }
777
778 #[test]
779 fn test_semantic_call_id_prefers_canonical_then_fallbacks() {
780 let mut canonical_attrs = HashMap::new();
781 canonical_attrs.insert(
782 ATTR_SEMANTIC_CALL_ID.to_string(),
783 serde_json::Value::String("cid-1".to_string()),
784 );
785 canonical_attrs.insert(
786 ATTR_LEGACY_CALL_ID.to_string(),
787 serde_json::Value::String("legacy-1".to_string()),
788 );
789 let canonical = Event {
790 event_id: "e-canonical".to_string(),
791 timestamp: Utc::now(),
792 event_type: EventType::ToolCall {
793 name: "shell".to_string(),
794 },
795 task_id: None,
796 content: Content::empty(),
797 duration_ms: None,
798 attributes: canonical_attrs,
799 };
800 assert_eq!(canonical.semantic_call_id(), Some("cid-1"));
801
802 let tool_result = Event {
803 event_id: "e-result".to_string(),
804 timestamp: Utc::now(),
805 event_type: EventType::ToolResult {
806 name: "shell".to_string(),
807 is_error: false,
808 call_id: Some(" cid-2 ".to_string()),
809 },
810 task_id: None,
811 content: Content::empty(),
812 duration_ms: None,
813 attributes: HashMap::new(),
814 };
815 assert_eq!(tool_result.semantic_call_id(), Some("cid-2"));
816
817 let mut legacy_attrs = HashMap::new();
818 legacy_attrs.insert(
819 ATTR_LEGACY_CALL_ID.to_string(),
820 serde_json::Value::String(" legacy-2 ".to_string()),
821 );
822 let legacy = Event {
823 event_id: "e-legacy".to_string(),
824 timestamp: Utc::now(),
825 event_type: EventType::ToolCall {
826 name: "shell".to_string(),
827 },
828 task_id: None,
829 content: Content::empty(),
830 duration_ms: None,
831 attributes: legacy_attrs,
832 };
833 assert_eq!(legacy.semantic_call_id(), Some("legacy-2"));
834 }
835}