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::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 pub fn to_jsonl(&self) -> Result<String, crate::jsonl::JsonlError> {
124 crate::jsonl::to_jsonl_string(self)
125 }
126
127 pub fn from_jsonl(s: &str) -> Result<Self, crate::jsonl::JsonlError> {
129 crate::jsonl::from_jsonl_str(s)
130 }
131
132 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#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct Agent {
145 pub provider: String,
147 pub model: String,
149 pub tool: String,
151 #[serde(skip_serializing_if = "Option::is_none")]
153 pub tool_version: Option<String>,
154}
155
156#[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#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct Event {
191 pub event_id: String,
193 pub timestamp: DateTime<Utc>,
195 pub event_type: EventType,
197 #[serde(skip_serializing_if = "Option::is_none")]
199 pub task_id: Option<String>,
200 pub content: Content,
202 #[serde(skip_serializing_if = "Option::is_none")]
204 pub duration_ms: Option<u64>,
205 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
207 pub attributes: HashMap<String, serde_json::Value>,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
212#[serde(tag = "type", content = "data")]
213#[non_exhaustive]
214pub enum EventType {
215 UserMessage,
217 AgentMessage,
218 SystemMessage,
219
220 Thinking,
222
223 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 ImageGenerate {
261 prompt: String,
262 },
263 VideoGenerate {
264 prompt: String,
265 },
266 AudioGenerate {
267 prompt: String,
268 },
269
270 WebSearch {
272 query: String,
273 },
274 WebFetch {
275 url: String,
276 },
277
278 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 Custom {
290 kind: String,
291 },
292}
293
294#[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#[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#[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}