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