Skip to main content

opensession_core/
trace.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5/// Top-level session - the root of a HAIL (Human AI Interaction Log) trace
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Session {
8    /// Format version, e.g. "hail-1.0.0"
9    pub version: String,
10    /// Unique session identifier (UUID)
11    pub session_id: String,
12    /// AI agent information
13    pub agent: Agent,
14    /// Session metadata
15    pub context: SessionContext,
16    /// Flat timeline of events
17    pub events: Vec<Event>,
18    /// Aggregate statistics
19    pub stats: Stats,
20}
21
22#[derive(Default)]
23struct StatsAcc {
24    message_count: u64,
25    user_message_count: u64,
26    tool_call_count: u64,
27    task_ids: std::collections::HashSet<String>,
28    total_input_tokens: u64,
29    total_output_tokens: u64,
30    changed_files: std::collections::HashSet<String>,
31    lines_added: u64,
32    lines_removed: u64,
33}
34
35impl StatsAcc {
36    fn process(mut self, event: &Event) -> Self {
37        match &event.event_type {
38            EventType::UserMessage => {
39                self.message_count += 1;
40                self.user_message_count += 1;
41            }
42            EventType::AgentMessage => self.message_count += 1,
43            EventType::TaskEnd { summary } => {
44                if summary
45                    .as_deref()
46                    .map(str::trim)
47                    .is_some_and(|text| !text.is_empty())
48                {
49                    self.message_count += 1;
50                }
51            }
52            EventType::ToolCall { .. }
53            | EventType::FileRead { .. }
54            | EventType::CodeSearch { .. }
55            | EventType::FileSearch { .. } => self.tool_call_count += 1,
56            EventType::FileEdit { path, diff } => {
57                self.changed_files.insert(path.clone());
58                if let Some(d) = diff {
59                    for line in d.lines() {
60                        if line.starts_with('+') && !line.starts_with("+++") {
61                            self.lines_added += 1;
62                        } else if line.starts_with('-') && !line.starts_with("---") {
63                            self.lines_removed += 1;
64                        }
65                    }
66                }
67            }
68            EventType::FileCreate { path } | EventType::FileDelete { path } => {
69                self.changed_files.insert(path.clone());
70            }
71            _ => {}
72        }
73        if let Some(ref tid) = event.task_id {
74            self.task_ids.insert(tid.clone());
75        }
76        if let Some(v) = event.attributes.get("input_tokens") {
77            self.total_input_tokens += v.as_u64().unwrap_or(0);
78        }
79        if let Some(v) = event.attributes.get("output_tokens") {
80            self.total_output_tokens += v.as_u64().unwrap_or(0);
81        }
82        self
83    }
84
85    fn into_stats(self, events: &[Event]) -> Stats {
86        let duration_seconds = if let (Some(first), Some(last)) = (events.first(), events.last()) {
87            (last.timestamp - first.timestamp).num_seconds().max(0) as u64
88        } else {
89            0
90        };
91
92        Stats {
93            event_count: events.len() as u64,
94            message_count: self.message_count,
95            tool_call_count: self.tool_call_count,
96            task_count: self.task_ids.len() as u64,
97            duration_seconds,
98            total_input_tokens: self.total_input_tokens,
99            total_output_tokens: self.total_output_tokens,
100            user_message_count: self.user_message_count,
101            files_changed: self.changed_files.len() as u64,
102            lines_added: self.lines_added,
103            lines_removed: self.lines_removed,
104        }
105    }
106}
107
108impl Session {
109    pub const CURRENT_VERSION: &'static str = "hail-1.0.0";
110
111    pub fn new(session_id: String, agent: Agent) -> Self {
112        Self {
113            version: Self::CURRENT_VERSION.to_string(),
114            session_id,
115            agent,
116            context: SessionContext::default(),
117            events: Vec::new(),
118            stats: Stats::default(),
119        }
120    }
121
122    /// Serialize to HAIL JSONL string
123    pub fn to_jsonl(&self) -> Result<String, crate::jsonl::JsonlError> {
124        crate::jsonl::to_jsonl_string(self)
125    }
126
127    /// Deserialize from HAIL JSONL string
128    pub fn from_jsonl(s: &str) -> Result<Self, crate::jsonl::JsonlError> {
129        crate::jsonl::from_jsonl_str(s)
130    }
131
132    /// Recompute stats from events
133    pub fn recompute_stats(&mut self) {
134        let acc = self
135            .events
136            .iter()
137            .fold(StatsAcc::default(), StatsAcc::process);
138        self.stats = acc.into_stats(&self.events);
139    }
140}
141
142/// AI agent information
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct Agent {
145    /// Provider: "anthropic", "openai", "local"
146    pub provider: String,
147    /// Model: "claude-opus-4-6", "gpt-4o"
148    pub model: String,
149    /// Tool: "claude-code", "codex", "cursor"
150    pub tool: String,
151    /// Tool version
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub tool_version: Option<String>,
154}
155
156/// Session context metadata
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct SessionContext {
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub title: Option<String>,
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub description: Option<String>,
163    #[serde(default)]
164    pub tags: Vec<String>,
165    pub created_at: DateTime<Utc>,
166    pub updated_at: DateTime<Utc>,
167    #[serde(default, skip_serializing_if = "Vec::is_empty")]
168    pub related_session_ids: Vec<String>,
169    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
170    pub attributes: HashMap<String, serde_json::Value>,
171}
172
173impl Default for SessionContext {
174    fn default() -> Self {
175        let now = Utc::now();
176        Self {
177            title: None,
178            description: None,
179            tags: Vec::new(),
180            created_at: now,
181            updated_at: now,
182            related_session_ids: Vec::new(),
183            attributes: HashMap::new(),
184        }
185    }
186}
187
188/// A single event in the flat timeline
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct Event {
191    /// Unique event identifier
192    pub event_id: String,
193    /// When this event occurred
194    pub timestamp: DateTime<Utc>,
195    /// Type of event
196    pub event_type: EventType,
197    /// Optional task grouping ID
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub task_id: Option<String>,
200    /// Multimodal content
201    pub content: Content,
202    /// Duration in milliseconds (for tool calls, etc.)
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub duration_ms: Option<u64>,
205    /// Arbitrary metadata
206    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
207    pub attributes: HashMap<String, serde_json::Value>,
208}
209
210/// Event type - the core abstraction
211#[derive(Debug, Clone, Serialize, Deserialize)]
212#[serde(tag = "type", content = "data")]
213#[non_exhaustive]
214pub enum EventType {
215    // Conversation
216    UserMessage,
217    AgentMessage,
218    SystemMessage,
219
220    // AI internals
221    Thinking,
222
223    // Tools/Actions
224    ToolCall {
225        name: String,
226    },
227    ToolResult {
228        name: String,
229        is_error: bool,
230        #[serde(skip_serializing_if = "Option::is_none")]
231        call_id: Option<String>,
232    },
233    FileRead {
234        path: String,
235    },
236    CodeSearch {
237        query: String,
238    },
239    FileSearch {
240        pattern: String,
241    },
242    FileEdit {
243        path: String,
244        #[serde(skip_serializing_if = "Option::is_none")]
245        diff: Option<String>,
246    },
247    FileCreate {
248        path: String,
249    },
250    FileDelete {
251        path: String,
252    },
253    ShellCommand {
254        command: String,
255        #[serde(skip_serializing_if = "Option::is_none")]
256        exit_code: Option<i32>,
257    },
258
259    // Multimodal generation
260    ImageGenerate {
261        prompt: String,
262    },
263    VideoGenerate {
264        prompt: String,
265    },
266    AudioGenerate {
267        prompt: String,
268    },
269
270    // Search/Reference
271    WebSearch {
272        query: String,
273    },
274    WebFetch {
275        url: String,
276    },
277
278    // Task boundary markers (optional)
279    TaskStart {
280        #[serde(skip_serializing_if = "Option::is_none")]
281        title: Option<String>,
282    },
283    TaskEnd {
284        #[serde(skip_serializing_if = "Option::is_none")]
285        summary: Option<String>,
286    },
287
288    // Extension point
289    Custom {
290        kind: String,
291    },
292}
293
294/// Multimodal content container
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct Content {
297    pub blocks: Vec<ContentBlock>,
298}
299
300impl Content {
301    pub fn empty() -> Self {
302        Self { blocks: Vec::new() }
303    }
304
305    pub fn text(text: impl Into<String>) -> Self {
306        Self {
307            blocks: vec![ContentBlock::Text { text: text.into() }],
308        }
309    }
310
311    pub fn code(code: impl Into<String>, language: Option<String>) -> Self {
312        Self {
313            blocks: vec![ContentBlock::Code {
314                code: code.into(),
315                language,
316                start_line: None,
317            }],
318        }
319    }
320}
321
322/// Individual content block
323#[derive(Debug, Clone, Serialize, Deserialize)]
324#[serde(tag = "type")]
325#[non_exhaustive]
326pub enum ContentBlock {
327    Text {
328        text: String,
329    },
330    Code {
331        code: String,
332        #[serde(skip_serializing_if = "Option::is_none")]
333        language: Option<String>,
334        #[serde(skip_serializing_if = "Option::is_none")]
335        start_line: Option<u32>,
336    },
337    Image {
338        url: String,
339        #[serde(skip_serializing_if = "Option::is_none")]
340        alt: Option<String>,
341        mime: String,
342    },
343    Video {
344        url: String,
345        mime: String,
346    },
347    Audio {
348        url: String,
349        mime: String,
350    },
351    File {
352        path: String,
353        #[serde(skip_serializing_if = "Option::is_none")]
354        content: Option<String>,
355    },
356    Json {
357        data: serde_json::Value,
358    },
359    Reference {
360        uri: String,
361        media_type: String,
362    },
363}
364
365/// Aggregate session statistics
366#[derive(Debug, Clone, Default, Serialize, Deserialize)]
367pub struct Stats {
368    pub event_count: u64,
369    pub message_count: u64,
370    pub tool_call_count: u64,
371    pub task_count: u64,
372    pub duration_seconds: u64,
373    #[serde(default)]
374    pub total_input_tokens: u64,
375    #[serde(default)]
376    pub total_output_tokens: u64,
377    #[serde(default)]
378    pub user_message_count: u64,
379    #[serde(default)]
380    pub files_changed: u64,
381    #[serde(default)]
382    pub lines_added: u64,
383    #[serde(default)]
384    pub lines_removed: u64,
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[test]
392    fn test_session_roundtrip() {
393        let session = Session::new(
394            "test-session-id".to_string(),
395            Agent {
396                provider: "anthropic".to_string(),
397                model: "claude-opus-4-6".to_string(),
398                tool: "claude-code".to_string(),
399                tool_version: Some("1.0.0".to_string()),
400            },
401        );
402
403        let json = serde_json::to_string_pretty(&session).unwrap();
404        let parsed: Session = serde_json::from_str(&json).unwrap();
405        assert_eq!(parsed.version, "hail-1.0.0");
406        assert_eq!(parsed.session_id, "test-session-id");
407        assert_eq!(parsed.agent.provider, "anthropic");
408    }
409
410    #[test]
411    fn test_event_type_serialization() {
412        let event_type = EventType::ToolCall {
413            name: "Read".to_string(),
414        };
415        let json = serde_json::to_string(&event_type).unwrap();
416        assert!(json.contains("ToolCall"));
417        assert!(json.contains("Read"));
418
419        let parsed: EventType = serde_json::from_str(&json).unwrap();
420        match parsed {
421            EventType::ToolCall { name } => assert_eq!(name, "Read"),
422            _ => panic!("Wrong variant"),
423        }
424    }
425
426    #[test]
427    fn test_content_block_variants() {
428        let blocks = vec![
429            ContentBlock::Text {
430                text: "Hello".to_string(),
431            },
432            ContentBlock::Code {
433                code: "fn main() {}".to_string(),
434                language: Some("rust".to_string()),
435                start_line: None,
436            },
437            ContentBlock::Image {
438                url: "https://example.com/img.png".to_string(),
439                alt: Some("Screenshot".to_string()),
440                mime: "image/png".to_string(),
441            },
442        ];
443
444        let content = Content { blocks };
445        let json = serde_json::to_string_pretty(&content).unwrap();
446        let parsed: Content = serde_json::from_str(&json).unwrap();
447        assert_eq!(parsed.blocks.len(), 3);
448    }
449
450    #[test]
451    fn test_recompute_stats() {
452        let mut session = Session::new(
453            "test".to_string(),
454            Agent {
455                provider: "anthropic".to_string(),
456                model: "claude-opus-4-6".to_string(),
457                tool: "claude-code".to_string(),
458                tool_version: None,
459            },
460        );
461
462        session.events.push(Event {
463            event_id: "e1".to_string(),
464            timestamp: Utc::now(),
465            event_type: EventType::UserMessage,
466            task_id: Some("t1".to_string()),
467            content: Content::text("hello"),
468            duration_ms: None,
469            attributes: HashMap::new(),
470        });
471
472        session.events.push(Event {
473            event_id: "e2".to_string(),
474            timestamp: Utc::now(),
475            event_type: EventType::ToolCall {
476                name: "Read".to_string(),
477            },
478            task_id: Some("t1".to_string()),
479            content: Content::empty(),
480            duration_ms: Some(100),
481            attributes: HashMap::new(),
482        });
483
484        session.events.push(Event {
485            event_id: "e3".to_string(),
486            timestamp: Utc::now(),
487            event_type: EventType::AgentMessage,
488            task_id: Some("t2".to_string()),
489            content: Content::text("done"),
490            duration_ms: None,
491            attributes: HashMap::new(),
492        });
493
494        session.recompute_stats();
495        assert_eq!(session.stats.event_count, 3);
496        assert_eq!(session.stats.message_count, 2);
497        assert_eq!(session.stats.tool_call_count, 1);
498        assert_eq!(session.stats.task_count, 2);
499    }
500
501    #[test]
502    fn test_recompute_stats_counts_task_end_summary_as_message() {
503        let mut session = Session::new(
504            "test-task-end-summary".to_string(),
505            Agent {
506                provider: "anthropic".to_string(),
507                model: "claude-opus-4-6".to_string(),
508                tool: "claude-code".to_string(),
509                tool_version: None,
510            },
511        );
512
513        let ts = Utc::now();
514        session.events.push(Event {
515            event_id: "u1".to_string(),
516            timestamp: ts,
517            event_type: EventType::UserMessage,
518            task_id: Some("t1".to_string()),
519            content: Content::text("do this"),
520            duration_ms: None,
521            attributes: HashMap::new(),
522        });
523        session.events.push(Event {
524            event_id: "t1-end".to_string(),
525            timestamp: ts,
526            event_type: EventType::TaskEnd {
527                summary: Some("finished successfully".to_string()),
528            },
529            task_id: Some("t1".to_string()),
530            content: Content::text("finished successfully"),
531            duration_ms: None,
532            attributes: HashMap::new(),
533        });
534
535        session.recompute_stats();
536        assert_eq!(session.stats.message_count, 2);
537        assert_eq!(session.stats.user_message_count, 1);
538    }
539
540    #[test]
541    fn test_file_read_serialization() {
542        let et = EventType::FileRead {
543            path: "/tmp/test.rs".to_string(),
544        };
545        let json = serde_json::to_string(&et).unwrap();
546        assert!(json.contains("FileRead"));
547        let parsed: EventType = serde_json::from_str(&json).unwrap();
548        match parsed {
549            EventType::FileRead { path } => assert_eq!(path, "/tmp/test.rs"),
550            _ => panic!("Expected FileRead"),
551        }
552    }
553
554    #[test]
555    fn test_code_search_serialization() {
556        let et = EventType::CodeSearch {
557            query: "fn main".to_string(),
558        };
559        let json = serde_json::to_string(&et).unwrap();
560        assert!(json.contains("CodeSearch"));
561        let parsed: EventType = serde_json::from_str(&json).unwrap();
562        match parsed {
563            EventType::CodeSearch { query } => assert_eq!(query, "fn main"),
564            _ => panic!("Expected CodeSearch"),
565        }
566    }
567
568    #[test]
569    fn test_file_search_serialization() {
570        let et = EventType::FileSearch {
571            pattern: "**/*.rs".to_string(),
572        };
573        let json = serde_json::to_string(&et).unwrap();
574        assert!(json.contains("FileSearch"));
575        let parsed: EventType = serde_json::from_str(&json).unwrap();
576        match parsed {
577            EventType::FileSearch { pattern } => assert_eq!(pattern, "**/*.rs"),
578            _ => panic!("Expected FileSearch"),
579        }
580    }
581
582    #[test]
583    fn test_tool_result_with_call_id() {
584        let et = EventType::ToolResult {
585            name: "Read".to_string(),
586            is_error: false,
587            call_id: Some("call-123".to_string()),
588        };
589        let json = serde_json::to_string(&et).unwrap();
590        assert!(json.contains("call_id"));
591        assert!(json.contains("call-123"));
592        let parsed: EventType = serde_json::from_str(&json).unwrap();
593        match parsed {
594            EventType::ToolResult {
595                name,
596                is_error,
597                call_id,
598            } => {
599                assert_eq!(name, "Read");
600                assert!(!is_error);
601                assert_eq!(call_id, Some("call-123".to_string()));
602            }
603            _ => panic!("Expected ToolResult"),
604        }
605    }
606
607    #[test]
608    fn test_tool_result_without_call_id() {
609        let et = EventType::ToolResult {
610            name: "Bash".to_string(),
611            is_error: true,
612            call_id: None,
613        };
614        let json = serde_json::to_string(&et).unwrap();
615        assert!(!json.contains("call_id"));
616        let parsed: EventType = serde_json::from_str(&json).unwrap();
617        match parsed {
618            EventType::ToolResult { call_id, .. } => assert_eq!(call_id, None),
619            _ => panic!("Expected ToolResult"),
620        }
621    }
622
623    #[test]
624    fn test_recompute_stats_new_tool_types() {
625        let mut session = Session::new(
626            "test2".to_string(),
627            Agent {
628                provider: "anthropic".to_string(),
629                model: "claude-opus-4-6".to_string(),
630                tool: "claude-code".to_string(),
631                tool_version: None,
632            },
633        );
634
635        let ts = Utc::now();
636        session.events.push(Event {
637            event_id: "e1".to_string(),
638            timestamp: ts,
639            event_type: EventType::FileRead {
640                path: "/tmp/a.rs".to_string(),
641            },
642            task_id: None,
643            content: Content::empty(),
644            duration_ms: None,
645            attributes: HashMap::new(),
646        });
647        session.events.push(Event {
648            event_id: "e2".to_string(),
649            timestamp: ts,
650            event_type: EventType::CodeSearch {
651                query: "fn main".to_string(),
652            },
653            task_id: None,
654            content: Content::empty(),
655            duration_ms: None,
656            attributes: HashMap::new(),
657        });
658        session.events.push(Event {
659            event_id: "e3".to_string(),
660            timestamp: ts,
661            event_type: EventType::FileSearch {
662                pattern: "*.rs".to_string(),
663            },
664            task_id: None,
665            content: Content::empty(),
666            duration_ms: None,
667            attributes: HashMap::new(),
668        });
669        session.events.push(Event {
670            event_id: "e4".to_string(),
671            timestamp: ts,
672            event_type: EventType::ToolCall {
673                name: "Task".to_string(),
674            },
675            task_id: None,
676            content: Content::empty(),
677            duration_ms: None,
678            attributes: HashMap::new(),
679        });
680
681        session.recompute_stats();
682        assert_eq!(session.stats.tool_call_count, 4);
683    }
684}