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