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    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    /// Serialize to HAIL JSONL string
136    pub fn to_jsonl(&self) -> Result<String, crate::jsonl::JsonlError> {
137        crate::jsonl::to_jsonl_string(self)
138    }
139
140    /// Deserialize from HAIL JSONL string
141    pub fn from_jsonl(s: &str) -> Result<Self, crate::jsonl::JsonlError> {
142        crate::jsonl::from_jsonl_str(s)
143    }
144
145    /// Recompute stats from events
146    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/// AI agent information
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct Agent {
158    /// Provider: "anthropic", "openai", "local"
159    pub provider: String,
160    /// Model: "claude-opus-4-6", "gpt-4o"
161    pub model: String,
162    /// Tool: "claude-code", "codex", "cursor"
163    pub tool: String,
164    /// Tool version
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub tool_version: Option<String>,
167}
168
169/// Session context metadata
170#[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/// A single event in the flat timeline
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct Event {
204    /// Unique event identifier
205    pub event_id: String,
206    /// When this event occurred
207    pub timestamp: DateTime<Utc>,
208    /// Type of event
209    pub event_type: EventType,
210    /// Optional task grouping ID
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub task_id: Option<String>,
213    /// Multimodal content
214    pub content: Content,
215    /// Duration in milliseconds (for tool calls, etc.)
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub duration_ms: Option<u64>,
218    /// Arbitrary metadata
219    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
220    pub attributes: HashMap<String, serde_json::Value>,
221}
222
223impl Event {
224    /// Return an attribute as trimmed string, ignoring empty values.
225    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    /// Return canonical source schema version (`source.schema_version`) if present.
234    pub fn source_schema_version(&self) -> Option<&str> {
235        self.attr_str(ATTR_SOURCE_SCHEMA_VERSION)
236    }
237
238    /// Return canonical source raw type (`source.raw_type`) if present.
239    pub fn source_raw_type(&self) -> Option<&str> {
240        self.attr_str(ATTR_SOURCE_RAW_TYPE)
241    }
242
243    /// Return canonical semantic group id (`semantic.group_id`) if present.
244    pub fn semantic_group_id(&self) -> Option<&str> {
245        self.attr_str(ATTR_SEMANTIC_GROUP_ID)
246    }
247
248    /// Return canonical semantic tool kind (`semantic.tool_kind`) if present.
249    pub fn semantic_tool_kind(&self) -> Option<&str> {
250        self.attr_str(ATTR_SEMANTIC_TOOL_KIND)
251    }
252
253    /// Resolve a stable tool call id for correlation.
254    ///
255    /// Resolution order:
256    /// 1) `semantic.call_id`
257    /// 2) `ToolResult.call_id`
258    /// 3) legacy `call_id` attribute
259    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/// Event type - the core abstraction
280#[derive(Debug, Clone, Serialize, Deserialize)]
281#[serde(tag = "type", content = "data")]
282#[non_exhaustive]
283pub enum EventType {
284    // Conversation
285    UserMessage,
286    AgentMessage,
287    SystemMessage,
288
289    // AI internals
290    Thinking,
291
292    // Tools/Actions
293    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    // Multimodal generation
329    ImageGenerate {
330        prompt: String,
331    },
332    VideoGenerate {
333        prompt: String,
334    },
335    AudioGenerate {
336        prompt: String,
337    },
338
339    // Search/Reference
340    WebSearch {
341        query: String,
342    },
343    WebFetch {
344        url: String,
345    },
346
347    // Task boundary markers (optional)
348    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    // Extension point
358    Custom {
359        kind: String,
360    },
361}
362
363/// Multimodal content container
364#[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/// Individual content block
392#[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/// Aggregate session statistics
435#[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}