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    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}