Skip to main content

magi_code/sessions/
event.rs

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}