1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Session {
8 pub version: String,
10 pub session_id: String,
12 pub agent: Agent,
14 pub context: SessionContext,
16 pub events: Vec<Event>,
18 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 pub fn to_jsonl(&self) -> Result<String, crate::jsonl::JsonlError> {
115 crate::jsonl::to_jsonl_string(self)
116 }
117
118 pub fn from_jsonl(s: &str) -> Result<Self, crate::jsonl::JsonlError> {
120 crate::jsonl::from_jsonl_str(s)
121 }
122
123 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#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct Agent {
136 pub provider: String,
138 pub model: String,
140 pub tool: String,
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub tool_version: Option<String>,
145}
146
147#[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#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct Event {
182 pub event_id: String,
184 pub timestamp: DateTime<Utc>,
186 pub event_type: EventType,
188 #[serde(skip_serializing_if = "Option::is_none")]
190 pub task_id: Option<String>,
191 pub content: Content,
193 #[serde(skip_serializing_if = "Option::is_none")]
195 pub duration_ms: Option<u64>,
196 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
198 pub attributes: HashMap<String, serde_json::Value>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
203#[serde(tag = "type", content = "data")]
204#[non_exhaustive]
205pub enum EventType {
206 UserMessage,
208 AgentMessage,
209 SystemMessage,
210
211 Thinking,
213
214 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 ImageGenerate {
252 prompt: String,
253 },
254 VideoGenerate {
255 prompt: String,
256 },
257 AudioGenerate {
258 prompt: String,
259 },
260
261 WebSearch {
263 query: String,
264 },
265 WebFetch {
266 url: String,
267 },
268
269 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 Custom {
281 kind: String,
282 },
283}
284
285#[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#[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#[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}