use super::resolve_session_scope_path;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum SessionEventKind {
UserInput,
AssistantChunk,
AssistantOutput,
ProviderResponseItem,
ReasoningSummary,
ToolCall,
ToolResult,
ProviderContextItem,
HookDiagnostic,
HookLifecycle,
HookContextInjection,
TurnStatus,
AbortRecovery,
ProviderStreamTrace,
ContextCache,
SessionTitle,
Compaction,
Diagnostic,
Rewind,
SubdirInstructionLoad,
TtsrInjection,
}
impl SessionEventKind {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::UserInput => "user_input",
Self::AssistantChunk => "assistant_chunk",
Self::AssistantOutput => "assistant_output",
Self::ProviderResponseItem => "provider_response_item",
Self::ReasoningSummary => "reasoning_summary",
Self::ToolCall => "tool_call",
Self::ToolResult => "tool_result",
Self::ProviderContextItem => "provider_context_item",
Self::HookDiagnostic => "hook_diagnostic",
Self::HookLifecycle => "hook_lifecycle",
Self::HookContextInjection => "hook_context_injection",
Self::TurnStatus => "turn_status",
Self::AbortRecovery => "abort_recovery",
Self::ProviderStreamTrace => "provider_stream_trace",
Self::ContextCache => "context_cache",
Self::SessionTitle => "session_title",
Self::Compaction => "compaction",
Self::Diagnostic => "diagnostic",
Self::Rewind => "rewind",
Self::SubdirInstructionLoad => "subdir_instruction_load",
Self::TtsrInjection => "ttsr_injection",
}
}
fn parse(value: &str) -> Option<Self> {
match value {
"user_input" => Some(Self::UserInput),
"assistant_chunk" => Some(Self::AssistantChunk),
"assistant_output" => Some(Self::AssistantOutput),
"provider_response_item" => Some(Self::ProviderResponseItem),
"reasoning_summary" => Some(Self::ReasoningSummary),
"tool_call" => Some(Self::ToolCall),
"tool_result" => Some(Self::ToolResult),
"provider_context_item" => Some(Self::ProviderContextItem),
"hook_diagnostic" => Some(Self::HookDiagnostic),
"hook_lifecycle" => Some(Self::HookLifecycle),
"hook_context_injection" => Some(Self::HookContextInjection),
"turn_status" => Some(Self::TurnStatus),
"abort_recovery" => Some(Self::AbortRecovery),
"provider_stream_trace" => Some(Self::ProviderStreamTrace),
"context_cache" => Some(Self::ContextCache),
"session_title" => Some(Self::SessionTitle),
"compaction" => Some(Self::Compaction),
"diagnostic" => Some(Self::Diagnostic),
"rewind" => Some(Self::Rewind),
"subdir_instruction_load" => Some(Self::SubdirInstructionLoad),
"ttsr_injection" => Some(Self::TtsrInjection),
_ => None,
}
}
pub(crate) const fn is_local_only(self) -> bool {
matches!(
self,
Self::HookDiagnostic
| Self::HookLifecycle
| Self::HookContextInjection
| Self::TurnStatus
| Self::AbortRecovery
| Self::ProviderStreamTrace
| Self::ContextCache
| Self::SessionTitle
| Self::Diagnostic
| Self::Rewind
| Self::SubdirInstructionLoad
| Self::TtsrInjection
)
}
}
impl AsRef<str> for SessionEventKind {
fn as_ref(&self) -> &str {
self.as_str()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionEvent {
pub event_type: String,
pub timestamp: DateTime<Utc>,
pub session_id: String,
pub cwd: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_path: Option<PathBuf>,
pub payload: Value,
}
impl SessionEvent {
pub fn new(
event_type: impl Into<String>,
session_id: String,
cwd: PathBuf,
payload: Value,
) -> Self {
Self {
event_type: event_type.into(),
timestamp: Utc::now(),
session_id,
cwd: cwd.clone(),
session_path: Some(resolve_session_scope_path(&cwd)),
payload,
}
}
pub(crate) fn new_kind(
kind: SessionEventKind,
session_id: String,
cwd: PathBuf,
payload: Value,
) -> Self {
Self::new(kind.as_str(), session_id, cwd, payload)
}
pub(crate) fn kind(&self) -> Option<SessionEventKind> {
SessionEventKind::parse(&self.event_type)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sessions::SessionManager;
use serde_json::json;
use tempfile::TempDir;
#[test]
fn ttsr_injection_event_round_trips_local_only() {
assert_eq!(SessionEventKind::TtsrInjection.as_str(), "ttsr_injection");
assert_eq!(
SessionEventKind::parse("ttsr_injection"),
Some(SessionEventKind::TtsrInjection)
);
assert!(SessionEventKind::TtsrInjection.is_local_only());
let temp = TempDir::new().unwrap();
let event = SessionEvent::new_kind(
SessionEventKind::TtsrInjection,
"session".to_string(),
temp.path().to_path_buf(),
json!({
"schema_version": 1,
"turn_index": 3,
"request_sequence": 2,
"rule_source": "builtin",
"rule_pattern": "danger",
"matched_text_redacted": "[REDACTED]",
"reminder": "stop",
"aborted_turn": 3
}),
);
let parsed: SessionEvent =
serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
assert_eq!(parsed.kind(), Some(SessionEventKind::TtsrInjection));
assert_eq!(parsed.payload["reminder"], "stop");
}
#[test]
fn rewind_event_round_trips_without_bytes() {
let temp = TempDir::new().unwrap();
let event = SessionEvent::new_kind(
SessionEventKind::Rewind,
"session".to_string(),
temp.path().to_path_buf(),
json!({
"target_turn": 2,
"latest_turn": 4,
"paths": [{"path":"src/lib.rs", "status":"restored"}],
"counts": {"Restored": 1}
}),
);
let serialized = serde_json::to_string(&event).unwrap();
assert!(!serialized.contains("old bytes"));
assert!(!serialized.contains("new bytes"));
let parsed: SessionEvent = serde_json::from_str(&serialized).unwrap();
assert_eq!(parsed.kind(), Some(SessionEventKind::Rewind));
assert_eq!(parsed.payload["target_turn"], 2);
}
#[test]
fn session_event_kind_preserves_jsonl_event_type_strings() {
let temp = TempDir::new().unwrap();
let manager = SessionManager::new(temp.path().join("sessions"));
let session = manager.create().unwrap();
let event = SessionEvent::new_kind(
SessionEventKind::UserInput,
session.id().to_string(),
temp.path().to_path_buf(),
json!({"text":"hello"}),
);
let serialized = serde_json::to_value(&event).unwrap();
assert_eq!(
serialized["event_type"],
SessionEventKind::UserInput.as_str()
);
assert!(serialized.get("kind").is_none());
assert_eq!(event.kind(), Some(SessionEventKind::UserInput));
assert_eq!(event.session_path.as_deref(), Some(temp.path()));
}
#[test]
fn session_event_serializes_session_path_and_accepts_legacy_missing_field() {
let temp = TempDir::new().unwrap();
let event = SessionEvent::new(
"user_input",
"safe".to_string(),
temp.path().to_path_buf(),
json!({"text":"hello"}),
);
let serialized = serde_json::to_value(&event).unwrap();
assert_eq!(serialized["session_path"].as_str(), temp.path().to_str());
let legacy = json!({
"event_type": "user_input",
"timestamp": event.timestamp,
"session_id": "legacy-session",
"cwd": temp.path(),
"payload": {"text":"old"}
});
let parsed: SessionEvent = serde_json::from_value(legacy).unwrap();
assert_eq!(parsed.session_path, None);
}
#[test]
fn hook_lifecycle_event_kind_is_local_only() {
assert_eq!(SessionEventKind::HookLifecycle.as_str(), "hook_lifecycle");
assert_eq!(
SessionEventKind::parse("hook_lifecycle"),
Some(SessionEventKind::HookLifecycle)
);
assert!(SessionEventKind::HookLifecycle.is_local_only());
assert_eq!(
SessionEventKind::HookContextInjection.as_str(),
"hook_context_injection"
);
assert_eq!(
SessionEventKind::parse("hook_context_injection"),
Some(SessionEventKind::HookContextInjection)
);
assert!(SessionEventKind::HookContextInjection.is_local_only());
assert_eq!(
SessionEventKind::ProviderContextItem.as_str(),
"provider_context_item"
);
assert_eq!(SessionEventKind::AbortRecovery.as_str(), "abort_recovery");
assert_eq!(
SessionEventKind::parse("abort_recovery"),
Some(SessionEventKind::AbortRecovery)
);
assert!(SessionEventKind::AbortRecovery.is_local_only());
assert_eq!(
SessionEventKind::ProviderStreamTrace.as_str(),
"provider_stream_trace"
);
assert_eq!(
SessionEventKind::parse("provider_stream_trace"),
Some(SessionEventKind::ProviderStreamTrace)
);
assert!(SessionEventKind::ProviderStreamTrace.is_local_only());
assert!(!SessionEventKind::ProviderContextItem.is_local_only());
assert!(!SessionEventKind::ReasoningSummary.is_local_only());
assert!(!SessionEventKind::ToolResult.is_local_only());
assert_eq!(
SessionEvent::new(
"future_event",
"safe".to_string(),
PathBuf::new(),
json!({})
)
.kind(),
None
);
}
#[test]
fn provider_stream_trace_event_kind_is_local_only() {
assert_eq!(
SessionEventKind::ProviderStreamTrace.as_str(),
"provider_stream_trace"
);
assert_eq!(
SessionEventKind::parse("provider_stream_trace"),
Some(SessionEventKind::ProviderStreamTrace)
);
assert!(SessionEventKind::ProviderStreamTrace.is_local_only());
}
#[test]
fn session_event_kind_tolerates_unknown_legacy_event_type() {
let temp = TempDir::new().unwrap();
let event = SessionEvent::new(
"legacy_future_event",
"legacy-session".to_string(),
temp.path().to_path_buf(),
json!({"value":1}),
);
let line = serde_json::to_string(&event).unwrap();
let parsed: SessionEvent = serde_json::from_str(&line).unwrap();
assert_eq!(parsed.event_type, "legacy_future_event");
assert_eq!(parsed.kind(), None);
assert_eq!(parsed.payload["value"], 1);
}
}