Skip to main content

kaizen/core/
event.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Core event + session-record types. Pure data, no IO.
3
4use 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    /// Discriminated by `payload["type"]` (e.g. todo_write, mode_transition).
15    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}