Skip to main content

kaizen/collect/hooks/
normalize.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Map HookEvent → core Event + derive SessionStatus.
3
4use crate::collect::hooks::{EventKind as HookKind, HookEvent};
5use crate::core::event::{Event, EventKind, EventSource, SessionStatus};
6
7/// Map HookEvent → Event. seq caller-supplied.
8pub fn hook_to_event(h: &HookEvent, seq: u64) -> Event {
9    let cost_usd_e6 = h
10        .payload
11        .get("total_cost_usd")
12        .and_then(|v| v.as_f64())
13        .map(|f| (f * 1_000_000.0) as i64);
14    Event {
15        session_id: h.session_id.clone(),
16        seq,
17        ts_ms: h.ts_ms,
18        ts_exact: true,
19        kind: EventKind::Hook,
20        source: EventSource::Hook,
21        tool: None,
22        tool_call_id: hook_tool_id(&h.payload),
23        tokens_in: None,
24        tokens_out: None,
25        reasoning_tokens: None,
26        cost_usd_e6,
27        stop_reason: None,
28        latency_ms: None,
29        ttft_ms: None,
30        retry_count: None,
31        context_used_tokens: None,
32        context_max_tokens: None,
33        cache_creation_tokens: None,
34        cache_read_tokens: None,
35        system_prompt_tokens: None,
36        payload: h.payload.clone(),
37    }
38}
39
40fn hook_tool_id(payload: &serde_json::Value) -> Option<String> {
41    ["tool_call_id", "tool_use_id", "call_id", "id"]
42        .iter()
43        .find_map(|k| payload.get(k).and_then(|v| v.as_str()))
44        .map(ToOwned::to_owned)
45}
46
47/// Derive target SessionStatus from hook kind.
48/// PreToolUse → Waiting, PostToolUse → Running, Stop → Done,
49/// SessionStart → Running, Unknown → None.
50pub fn hook_to_status(kind: &HookKind) -> Option<SessionStatus> {
51    match kind {
52        HookKind::PreToolUse => Some(SessionStatus::Waiting),
53        HookKind::PostToolUse => Some(SessionStatus::Running),
54        HookKind::Stop => Some(SessionStatus::Done),
55        HookKind::SessionStart => Some(SessionStatus::Running),
56        HookKind::Unknown(_) => None,
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63    use crate::collect::hooks::HookEvent;
64    use serde_json::json;
65
66    fn make_event(kind: HookKind) -> HookEvent {
67        HookEvent {
68            kind,
69            session_id: "s1".to_string(),
70            ts_ms: 1000,
71            payload: json!({}),
72        }
73    }
74
75    #[test]
76    fn session_start_maps_running() {
77        assert_eq!(
78            hook_to_status(&HookKind::SessionStart),
79            Some(SessionStatus::Running)
80        );
81    }
82
83    #[test]
84    fn pre_tool_use_maps_waiting() {
85        assert_eq!(
86            hook_to_status(&HookKind::PreToolUse),
87            Some(SessionStatus::Waiting)
88        );
89    }
90
91    #[test]
92    fn post_tool_use_maps_running() {
93        assert_eq!(
94            hook_to_status(&HookKind::PostToolUse),
95            Some(SessionStatus::Running)
96        );
97    }
98
99    #[test]
100    fn stop_maps_done() {
101        assert_eq!(hook_to_status(&HookKind::Stop), Some(SessionStatus::Done));
102    }
103
104    #[test]
105    fn unknown_maps_none() {
106        assert_eq!(hook_to_status(&HookKind::Unknown("x".to_string())), None);
107    }
108
109    #[test]
110    fn hook_to_event_kind_is_hook() {
111        let h = make_event(HookKind::Stop);
112        let ev = hook_to_event(&h, 5);
113        assert_eq!(ev.kind, EventKind::Hook);
114        assert_eq!(ev.seq, 5);
115        assert_eq!(ev.session_id, "s1");
116    }
117
118    #[test]
119    fn hook_to_event_maps_total_cost_usd_to_microdollars() {
120        let h = HookEvent {
121            kind: HookKind::Stop,
122            session_id: "s1".to_string(),
123            ts_ms: 1000,
124            payload: json!({ "total_cost_usd": 0.042 }),
125        };
126        let ev = hook_to_event(&h, 0);
127        assert_eq!(ev.cost_usd_e6, Some(42_000));
128    }
129}