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