Skip to main content

opensession_core/
trace.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5/// Canonical event attribute key for source schema version.
6pub const ATTR_SOURCE_SCHEMA_VERSION: &str = "source.schema_version";
7/// Canonical event attribute key for source raw record type.
8pub const ATTR_SOURCE_RAW_TYPE: &str = "source.raw_type";
9/// Canonical event attribute key for semantic group/turn identifier.
10pub const ATTR_SEMANTIC_GROUP_ID: &str = "semantic.group_id";
11/// Canonical event attribute key for semantic tool call identifier.
12pub const ATTR_SEMANTIC_CALL_ID: &str = "semantic.call_id";
13/// Canonical event attribute key for semantic tool kind classification.
14pub const ATTR_SEMANTIC_TOOL_KIND: &str = "semantic.tool_kind";
15/// Legacy attribute key historically used for tool call correlation.
16pub const ATTR_LEGACY_CALL_ID: &str = "call_id";
17
18/// Top-level session - the root of a HAIL (Human AI Interaction Log) trace
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Session {
21    /// Format version, e.g. "hail-1.0.0"
22    pub version: String,
23    /// Unique session identifier (UUID)
24    pub session_id: String,
25    /// AI agent information
26    pub agent: Agent,
27    /// Session metadata
28    pub context: SessionContext,
29    /// Flat timeline of events
30    pub events: Vec<Event>,
31    /// Aggregate statistics
32    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    /// Serialize to HAIL JSONL string
156    pub fn to_jsonl(&self) -> Result<String, crate::jsonl::JsonlError> {
157        crate::jsonl::to_jsonl_string(self)
158    }
159
160    /// Deserialize from HAIL JSONL string
161    pub fn from_jsonl(s: &str) -> Result<Self, crate::jsonl::JsonlError> {
162        crate::jsonl::from_jsonl_str(s)
163    }
164
165    /// Recompute stats from events
166    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/// AI agent information
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct Agent {
178    /// Provider: "anthropic", "openai", "local"
179    pub provider: String,
180    /// Model: "claude-opus-4-6", "gpt-4o"
181    pub model: String,
182    /// Tool: "claude-code", "codex", "cursor"
183    pub tool: String,
184    /// Tool version
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub tool_version: Option<String>,
187}
188
189/// Session context metadata
190#[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/// A single event in the flat timeline
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct Event {
224    /// Unique event identifier
225    pub event_id: String,
226    /// When this event occurred
227    pub timestamp: DateTime<Utc>,
228    /// Type of event
229    pub event_type: EventType,
230    /// Optional task grouping ID
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub task_id: Option<String>,
233    /// Multimodal content
234    pub content: Content,
235    /// Duration in milliseconds (for tool calls, etc.)
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub duration_ms: Option<u64>,
238    /// Arbitrary metadata
239    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
240    pub attributes: HashMap<String, serde_json::Value>,
241}
242
243impl Event {
244    /// Return an attribute as trimmed string, ignoring empty values.
245    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    /// Return canonical source schema version (`source.schema_version`) if present.
254    pub fn source_schema_version(&self) -> Option<&str> {
255        self.attr_str(ATTR_SOURCE_SCHEMA_VERSION)
256    }
257
258    /// Return canonical source raw type (`source.raw_type`) if present.
259    pub fn source_raw_type(&self) -> Option<&str> {
260        self.attr_str(ATTR_SOURCE_RAW_TYPE)
261    }
262
263    /// Return canonical semantic group id (`semantic.group_id`) if present.
264    pub fn semantic_group_id(&self) -> Option<&str> {
265        self.attr_str(ATTR_SEMANTIC_GROUP_ID)
266    }
267
268    /// Return canonical semantic tool kind (`semantic.tool_kind`) if present.
269    pub fn semantic_tool_kind(&self) -> Option<&str> {
270        self.attr_str(ATTR_SEMANTIC_TOOL_KIND)
271    }
272
273    /// Resolve a stable tool call id for correlation.
274    ///
275    /// Resolution order:
276    /// 1) `semantic.call_id`
277    /// 2) `ToolResult.call_id`
278    /// 3) legacy `call_id` attribute
279    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/// Event type - the core abstraction
300#[derive(Debug, Clone, Serialize, Deserialize)]
301#[serde(tag = "type", content = "data")]
302#[non_exhaustive]
303pub enum EventType {
304    // Conversation
305    UserMessage,
306    AgentMessage,
307    SystemMessage,
308
309    // AI internals
310    Thinking,
311
312    // Tools/Actions
313    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    // Multimodal generation
349    ImageGenerate {
350        prompt: String,
351    },
352    VideoGenerate {
353        prompt: String,
354    },
355    AudioGenerate {
356        prompt: String,
357    },
358
359    // Search/Reference
360    WebSearch {
361        query: String,
362    },
363    WebFetch {
364        url: String,
365    },
366
367    // Task boundary markers (optional)
368    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    // Extension point
378    Custom {
379        kind: String,
380    },
381}
382
383/// Multimodal content container
384#[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/// Individual content block
412#[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/// Aggregate session statistics
455#[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}