1use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7pub enum EventKind {
8 ToolCall,
9 ToolResult,
10 Message,
11 Error,
12 Cost,
13 Hook,
14 Lifecycle,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub enum EventSource {
20 Tail,
21 Hook,
22 Proxy,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Event {
27 pub session_id: String,
28 pub seq: u64,
29 pub ts_ms: u64,
30 pub ts_exact: bool,
31 pub kind: EventKind,
32 pub source: EventSource,
33 pub tool: Option<String>,
34 pub tool_call_id: Option<String>,
35 pub tokens_in: Option<u32>,
36 pub tokens_out: Option<u32>,
37 pub reasoning_tokens: Option<u32>,
38 pub cost_usd_e6: Option<i64>,
39 pub stop_reason: Option<String>,
40 pub latency_ms: Option<u32>,
41 pub ttft_ms: Option<u32>,
42 pub retry_count: Option<u16>,
43 pub context_used_tokens: Option<u32>,
44 pub context_max_tokens: Option<u32>,
45 pub cache_creation_tokens: Option<u32>,
46 pub cache_read_tokens: Option<u32>,
47 pub system_prompt_tokens: Option<u32>,
48 pub payload: serde_json::Value,
49}
50
51impl Event {
52 pub fn normalize_legacy_hook(mut self) -> Self {
53 if !self.is_legacy_hook() {
54 return self;
55 }
56 self.kind = legacy_hook_kind(&self.payload).unwrap_or(EventKind::Hook);
57 self.tool = self.tool.or_else(|| legacy_hook_tool(&self.payload));
58 self
59 }
60
61 fn is_legacy_hook(&self) -> bool {
62 self.source == EventSource::Hook && self.kind == EventKind::Hook
63 }
64}
65
66fn legacy_hook_kind(payload: &serde_json::Value) -> Option<EventKind> {
67 match hook_name(payload)? {
68 "PreToolUse" | "pre_tool_use" => Some(EventKind::ToolCall),
69 "PostToolUse" | "post_tool_use" => Some(EventKind::ToolResult),
70 "SessionStart" | "session_start" | "Stop" | "stop" => Some(EventKind::Lifecycle),
71 _ => None,
72 }
73}
74
75fn legacy_hook_tool(payload: &serde_json::Value) -> Option<String> {
76 ["tool_name", "tool"]
77 .iter()
78 .find_map(|key| payload.get(key).and_then(|value| value.as_str()))
79 .map(ToOwned::to_owned)
80}
81
82fn hook_name(payload: &serde_json::Value) -> Option<&str> {
83 payload
84 .get("hook_event_name")
85 .or_else(|| payload.get("event"))
86 .and_then(|value| value.as_str())
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub enum SessionStatus {
91 Running,
92 Waiting,
93 Idle,
94 Done,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct SessionRecord {
99 pub id: String,
100 pub agent: String,
101 pub model: Option<String>,
102 pub workspace: String,
103 pub started_at_ms: u64,
104 pub ended_at_ms: Option<u64>,
105 pub status: SessionStatus,
106 pub trace_path: String,
107 pub start_commit: Option<String>,
108 pub end_commit: Option<String>,
109 pub branch: Option<String>,
110 pub dirty_start: Option<bool>,
111 pub dirty_end: Option<bool>,
112 pub repo_binding_source: Option<String>,
113 pub prompt_fingerprint: Option<String>,
114 pub parent_session_id: Option<String>,
115 pub agent_version: Option<String>,
116 pub os: Option<String>,
117 pub arch: Option<String>,
118 pub repo_file_count: Option<u32>,
119 pub repo_total_loc: Option<u64>,
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use serde_json::json;
126
127 #[test]
128 fn event_serde_round_trip() {
129 let e = Event {
130 session_id: "s1".to_string(),
131 seq: 0,
132 ts_ms: 1000,
133 ts_exact: false,
134 kind: EventKind::ToolCall,
135 source: EventSource::Tail,
136 tool: Some("read_file".to_string()),
137 tool_call_id: Some("call_1".to_string()),
138 tokens_in: None,
139 tokens_out: None,
140 reasoning_tokens: None,
141 cost_usd_e6: None,
142 stop_reason: None,
143 latency_ms: None,
144 ttft_ms: None,
145 retry_count: None,
146 context_used_tokens: None,
147 context_max_tokens: None,
148 cache_creation_tokens: None,
149 cache_read_tokens: None,
150 system_prompt_tokens: None,
151 payload: json!({"path": "src/main.rs"}),
152 };
153 let s = serde_json::to_string(&e).unwrap();
154 let e2: Event = serde_json::from_str(&s).unwrap();
155 assert_eq!(e.session_id, e2.session_id);
156 assert_eq!(e.kind, e2.kind);
157 assert_eq!(e.tool, e2.tool);
158 }
159
160 #[test]
161 fn session_record_serde_round_trip() {
162 let r = SessionRecord {
163 id: "abc".to_string(),
164 agent: "cursor".to_string(),
165 model: Some("gpt-4".to_string()),
166 workspace: "/home/user/proj".to_string(),
167 started_at_ms: 0,
168 ended_at_ms: Some(9999),
169 status: SessionStatus::Done,
170 trace_path: "/tmp/abc".to_string(),
171 start_commit: Some("abc".to_string()),
172 end_commit: Some("def".to_string()),
173 branch: Some("main".to_string()),
174 dirty_start: Some(false),
175 dirty_end: Some(true),
176 repo_binding_source: Some("git".to_string()),
177 prompt_fingerprint: None,
178 parent_session_id: None,
179 agent_version: None,
180 os: None,
181 arch: None,
182 repo_file_count: None,
183 repo_total_loc: None,
184 };
185 let s = serde_json::to_string(&r).unwrap();
186 let r2: SessionRecord = serde_json::from_str(&s).unwrap();
187 assert_eq!(r.id, r2.id);
188 assert_eq!(r.status, r2.status);
189 assert_eq!(r.ended_at_ms, r2.ended_at_ms);
190 }
191}