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