1use super::resolve_session_scope_path;
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::path::PathBuf;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8pub(crate) enum SessionEventKind {
9 UserInput,
10 AssistantChunk,
11 AssistantOutput,
12 ProviderResponseItem,
13 ReasoningSummary,
14 ToolCall,
15 ToolResult,
16 ToolDisplayResult,
17 ProviderContextItem,
18 HookDiagnostic,
19 HookLifecycle,
20 HookContextInjection,
21 TurnStatus,
22 AbortRecovery,
23 ProviderStreamTrace,
24 ContextCache,
25 SessionTitle,
26 Compaction,
27 Diagnostic,
28 Rewind,
29 SubdirInstructionLoad,
30 TtsrInjection,
31}
32
33impl SessionEventKind {
34 pub(crate) const fn as_str(self) -> &'static str {
35 match self {
36 Self::UserInput => "user_input",
37 Self::AssistantChunk => "assistant_chunk",
38 Self::AssistantOutput => "assistant_output",
39 Self::ProviderResponseItem => "provider_response_item",
40 Self::ReasoningSummary => "reasoning_summary",
41 Self::ToolCall => "tool_call",
42 Self::ToolResult => "tool_result",
43 Self::ToolDisplayResult => "tool_display_result",
44 Self::ProviderContextItem => "provider_context_item",
45 Self::HookDiagnostic => "hook_diagnostic",
46 Self::HookLifecycle => "hook_lifecycle",
47 Self::HookContextInjection => "hook_context_injection",
48 Self::TurnStatus => "turn_status",
49 Self::AbortRecovery => "abort_recovery",
50 Self::ProviderStreamTrace => "provider_stream_trace",
51 Self::ContextCache => "context_cache",
52 Self::SessionTitle => "session_title",
53 Self::Compaction => "compaction",
54 Self::Diagnostic => "diagnostic",
55 Self::Rewind => "rewind",
56 Self::SubdirInstructionLoad => "subdir_instruction_load",
57 Self::TtsrInjection => "ttsr_injection",
58 }
59 }
60
61 fn parse(value: &str) -> Option<Self> {
62 match value {
63 "user_input" => Some(Self::UserInput),
64 "assistant_chunk" => Some(Self::AssistantChunk),
65 "assistant_output" => Some(Self::AssistantOutput),
66 "provider_response_item" => Some(Self::ProviderResponseItem),
67 "reasoning_summary" => Some(Self::ReasoningSummary),
68 "tool_call" => Some(Self::ToolCall),
69 "tool_result" => Some(Self::ToolResult),
70 "tool_display_result" => Some(Self::ToolDisplayResult),
71 "provider_context_item" => Some(Self::ProviderContextItem),
72 "hook_diagnostic" => Some(Self::HookDiagnostic),
73 "hook_lifecycle" => Some(Self::HookLifecycle),
74 "hook_context_injection" => Some(Self::HookContextInjection),
75 "turn_status" => Some(Self::TurnStatus),
76 "abort_recovery" => Some(Self::AbortRecovery),
77 "provider_stream_trace" => Some(Self::ProviderStreamTrace),
78 "context_cache" => Some(Self::ContextCache),
79 "session_title" => Some(Self::SessionTitle),
80 "compaction" => Some(Self::Compaction),
81 "diagnostic" => Some(Self::Diagnostic),
82 "rewind" => Some(Self::Rewind),
83 "subdir_instruction_load" => Some(Self::SubdirInstructionLoad),
84 "ttsr_injection" => Some(Self::TtsrInjection),
85 _ => None,
86 }
87 }
88
89 pub(crate) const fn is_local_only(self) -> bool {
90 matches!(
91 self,
92 Self::HookDiagnostic
93 | Self::ToolDisplayResult
94 | Self::HookLifecycle
95 | Self::HookContextInjection
96 | Self::TurnStatus
97 | Self::AbortRecovery
98 | Self::ProviderStreamTrace
99 | Self::ContextCache
100 | Self::SessionTitle
101 | Self::Diagnostic
102 | Self::Rewind
103 | Self::SubdirInstructionLoad
104 | Self::TtsrInjection
105 )
106 }
107}
108
109impl AsRef<str> for SessionEventKind {
110 fn as_ref(&self) -> &str {
111 self.as_str()
112 }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
116pub struct SessionEvent {
117 pub event_type: String,
118 pub timestamp: DateTime<Utc>,
119 pub session_id: String,
120 pub cwd: PathBuf,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub session_path: Option<PathBuf>,
123 pub payload: Value,
124}
125
126impl SessionEvent {
127 pub fn new(
128 event_type: impl Into<String>,
129 session_id: String,
130 cwd: PathBuf,
131 payload: Value,
132 ) -> Self {
133 Self {
134 event_type: event_type.into(),
135 timestamp: Utc::now(),
136 session_id,
137 cwd: cwd.clone(),
138 session_path: Some(resolve_session_scope_path(&cwd)),
139 payload,
140 }
141 }
142
143 pub(crate) fn new_kind(
144 kind: SessionEventKind,
145 session_id: String,
146 cwd: PathBuf,
147 payload: Value,
148 ) -> Self {
149 Self::new(kind.as_str(), session_id, cwd, payload)
150 }
151
152 pub(crate) fn kind(&self) -> Option<SessionEventKind> {
153 SessionEventKind::parse(&self.event_type)
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use crate::sessions::SessionManager;
161 use serde_json::json;
162 use tempfile::TempDir;
163
164 #[test]
165 fn ttsr_injection_event_round_trips_local_only() {
166 assert_eq!(SessionEventKind::TtsrInjection.as_str(), "ttsr_injection");
167 assert_eq!(
168 SessionEventKind::parse("ttsr_injection"),
169 Some(SessionEventKind::TtsrInjection)
170 );
171 assert!(SessionEventKind::TtsrInjection.is_local_only());
172 let temp = TempDir::new().unwrap();
173 let event = SessionEvent::new_kind(
174 SessionEventKind::TtsrInjection,
175 "session".to_string(),
176 temp.path().to_path_buf(),
177 json!({
178 "schema_version": 1,
179 "turn_index": 3,
180 "request_sequence": 2,
181 "rule_source": "builtin",
182 "rule_pattern": "danger",
183 "matched_text_redacted": "[REDACTED]",
184 "reminder": "stop",
185 "aborted_turn": 3
186 }),
187 );
188 let parsed: SessionEvent =
189 serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
190 assert_eq!(parsed.kind(), Some(SessionEventKind::TtsrInjection));
191 assert_eq!(parsed.payload["reminder"], "stop");
192 }
193
194 #[test]
195 fn rewind_event_round_trips_without_bytes() {
196 let temp = TempDir::new().unwrap();
197 let event = SessionEvent::new_kind(
198 SessionEventKind::Rewind,
199 "session".to_string(),
200 temp.path().to_path_buf(),
201 json!({
202 "target_turn": 2,
203 "latest_turn": 4,
204 "paths": [{"path":"src/lib.rs", "status":"restored"}],
205 "counts": {"Restored": 1}
206 }),
207 );
208
209 let serialized = serde_json::to_string(&event).unwrap();
210 assert!(!serialized.contains("old bytes"));
211 assert!(!serialized.contains("new bytes"));
212 let parsed: SessionEvent = serde_json::from_str(&serialized).unwrap();
213 assert_eq!(parsed.kind(), Some(SessionEventKind::Rewind));
214 assert_eq!(parsed.payload["target_turn"], 2);
215 }
216
217 #[test]
218 fn session_event_kind_preserves_jsonl_event_type_strings() {
219 let temp = TempDir::new().unwrap();
220 let manager = SessionManager::new(temp.path().join("sessions"));
221 let session = manager.create().unwrap();
222 let event = SessionEvent::new_kind(
223 SessionEventKind::UserInput,
224 session.id().to_string(),
225 temp.path().to_path_buf(),
226 json!({"text":"hello"}),
227 );
228
229 let serialized = serde_json::to_value(&event).unwrap();
230 assert_eq!(
231 serialized["event_type"],
232 SessionEventKind::UserInput.as_str()
233 );
234 assert!(serialized.get("kind").is_none());
235 assert_eq!(event.kind(), Some(SessionEventKind::UserInput));
236 assert_eq!(event.session_path.as_deref(), Some(temp.path()));
237 }
238
239 #[test]
240 fn session_event_serializes_session_path_and_accepts_legacy_missing_field() {
241 let temp = TempDir::new().unwrap();
242 let event = SessionEvent::new(
243 "user_input",
244 "safe".to_string(),
245 temp.path().to_path_buf(),
246 json!({"text":"hello"}),
247 );
248
249 let serialized = serde_json::to_value(&event).unwrap();
250 assert_eq!(serialized["session_path"].as_str(), temp.path().to_str());
251
252 let legacy = json!({
253 "event_type": "user_input",
254 "timestamp": event.timestamp,
255 "session_id": "legacy-session",
256 "cwd": temp.path(),
257 "payload": {"text":"old"}
258 });
259 let parsed: SessionEvent = serde_json::from_value(legacy).unwrap();
260 assert_eq!(parsed.session_path, None);
261 }
262
263 #[test]
264 fn hook_lifecycle_event_kind_is_local_only() {
265 assert_eq!(SessionEventKind::HookLifecycle.as_str(), "hook_lifecycle");
266 assert_eq!(
267 SessionEventKind::parse("hook_lifecycle"),
268 Some(SessionEventKind::HookLifecycle)
269 );
270 assert!(SessionEventKind::HookLifecycle.is_local_only());
271 assert_eq!(
272 SessionEventKind::HookContextInjection.as_str(),
273 "hook_context_injection"
274 );
275 assert_eq!(
276 SessionEventKind::parse("hook_context_injection"),
277 Some(SessionEventKind::HookContextInjection)
278 );
279 assert!(SessionEventKind::HookContextInjection.is_local_only());
280 assert_eq!(
281 SessionEventKind::ProviderContextItem.as_str(),
282 "provider_context_item"
283 );
284 assert_eq!(SessionEventKind::AbortRecovery.as_str(), "abort_recovery");
285 assert_eq!(
286 SessionEventKind::parse("abort_recovery"),
287 Some(SessionEventKind::AbortRecovery)
288 );
289 assert!(SessionEventKind::AbortRecovery.is_local_only());
290 assert_eq!(
291 SessionEventKind::ProviderStreamTrace.as_str(),
292 "provider_stream_trace"
293 );
294 assert_eq!(
295 SessionEventKind::parse("provider_stream_trace"),
296 Some(SessionEventKind::ProviderStreamTrace)
297 );
298 assert!(SessionEventKind::ProviderStreamTrace.is_local_only());
299 assert!(!SessionEventKind::ProviderContextItem.is_local_only());
300 assert!(!SessionEventKind::ReasoningSummary.is_local_only());
301 assert!(!SessionEventKind::ToolResult.is_local_only());
302 assert_eq!(
303 SessionEvent::new(
304 "future_event",
305 "safe".to_string(),
306 PathBuf::new(),
307 json!({})
308 )
309 .kind(),
310 None
311 );
312 }
313
314 #[test]
315 fn provider_stream_trace_event_kind_is_local_only() {
316 assert_eq!(
317 SessionEventKind::ProviderStreamTrace.as_str(),
318 "provider_stream_trace"
319 );
320 assert_eq!(
321 SessionEventKind::parse("provider_stream_trace"),
322 Some(SessionEventKind::ProviderStreamTrace)
323 );
324 assert!(SessionEventKind::ProviderStreamTrace.is_local_only());
325 }
326
327 #[test]
328 fn session_event_kind_tolerates_unknown_legacy_event_type() {
329 let temp = TempDir::new().unwrap();
330 let event = SessionEvent::new(
331 "legacy_future_event",
332 "legacy-session".to_string(),
333 temp.path().to_path_buf(),
334 json!({"value":1}),
335 );
336 let line = serde_json::to_string(&event).unwrap();
337 let parsed: SessionEvent = serde_json::from_str(&line).unwrap();
338
339 assert_eq!(parsed.event_type, "legacy_future_event");
340 assert_eq!(parsed.kind(), None);
341 assert_eq!(parsed.payload["value"], 1);
342 }
343}