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