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