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
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 pub fn to_jsonl(&self) -> Result<String, crate::jsonl::JsonlError> {
38 crate::jsonl::to_jsonl_string(self)
39 }
40
41 pub fn from_jsonl(s: &str) -> Result<Self, crate::jsonl::JsonlError> {
43 crate::jsonl::from_jsonl_str(s)
44 }
45
46 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#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct Agent {
126 pub provider: String,
128 pub model: String,
130 pub tool: String,
132 #[serde(skip_serializing_if = "Option::is_none")]
134 pub tool_version: Option<String>,
135}
136
137#[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#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct Event {
172 pub event_id: String,
174 pub timestamp: DateTime<Utc>,
176 pub event_type: EventType,
178 #[serde(skip_serializing_if = "Option::is_none")]
180 pub task_id: Option<String>,
181 pub content: Content,
183 #[serde(skip_serializing_if = "Option::is_none")]
185 pub duration_ms: Option<u64>,
186 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
188 pub attributes: HashMap<String, serde_json::Value>,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
193#[serde(tag = "type", content = "data")]
194#[non_exhaustive]
195pub enum EventType {
196 UserMessage,
198 AgentMessage,
199 SystemMessage,
200
201 Thinking,
203
204 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 ImageGenerate {
242 prompt: String,
243 },
244 VideoGenerate {
245 prompt: String,
246 },
247 AudioGenerate {
248 prompt: String,
249 },
250
251 WebSearch {
253 query: String,
254 },
255 WebFetch {
256 url: String,
257 },
258
259 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 Custom {
271 kind: String,
272 },
273}
274
275#[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#[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#[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}