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,
ToolDisplayResult,
SubagentSession,
ProviderContextItem,
HookDiagnostic,
HookLifecycle,
HookContextInjection,
TurnStatus,
AbortRecovery,
ProviderStreamTrace,
ContextCache,
SessionTitle,
Compaction,
Diagnostic,
Rewind,
SubdirInstructionLoad,
TtsrInjection,
SessionUsage,
}
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::ToolDisplayResult => "tool_display_result",
Self::SubagentSession => "subagent_session",
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",
Self::SessionUsage => "session_usage",
}
}
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),
"tool_display_result" => Some(Self::ToolDisplayResult),
"subagent_session" => Some(Self::SubagentSession),
"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),
"session_usage" => Some(Self::SessionUsage),
_ => None,
}
}
pub(crate) const fn is_local_only(self) -> bool {
matches!(
self,
Self::HookDiagnostic
| Self::ToolDisplayResult
| Self::SubagentSession
| Self::HookLifecycle
| Self::HookContextInjection
| Self::TurnStatus
| Self::AbortRecovery
| Self::ProviderStreamTrace
| Self::ContextCache
| Self::SessionTitle
| Self::Diagnostic
| Self::Rewind
| Self::SubdirInstructionLoad
| Self::TtsrInjection
| Self::SessionUsage
)
}
}
impl AsRef<str> for SessionEventKind {
fn as_ref(&self) -> &str {
self.as_str()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum TurnStatus {
Incomplete,
#[serde(alias = "canceled")]
Cancelled,
Failed,
CompactionRequired,
Complete,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct TurnStatusPayload {
pub(crate) status: TurnStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) assistant_text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) error_summary: Option<String>,
}
#[derive(Deserialize)]
struct RawTurnStatusPayload {
status: TurnStatus,
#[serde(default)]
assistant_text: Option<String>,
#[serde(default)]
error_summary: Option<String>,
}
impl<'de> Deserialize<'de> for TurnStatusPayload {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = RawTurnStatusPayload::deserialize(deserializer)?;
Ok(Self::new_with_error_summary(
raw.status,
raw.assistant_text.as_deref(),
raw.error_summary.as_deref(),
))
}
}
const TURN_STATUS_ERROR_SUMMARY_MAX_CHARS: usize = 2_000;
fn sanitize_turn_status_error_summary(summary: &str) -> Option<String> {
let sanitized = crate::output::sanitize_display_text(summary);
let trimmed = sanitized.trim();
(!trimmed.is_empty()).then(|| {
trimmed
.chars()
.take(TURN_STATUS_ERROR_SUMMARY_MAX_CHARS)
.collect()
})
}
impl TurnStatusPayload {
pub(crate) fn new(status: TurnStatus, assistant_text: Option<&str>) -> Self {
Self::new_with_error_summary(status, assistant_text, None)
}
pub(crate) fn new_with_error_summary(
status: TurnStatus,
assistant_text: Option<&str>,
error_summary: Option<&str>,
) -> Self {
Self {
status,
assistant_text: assistant_text.map(str::to_string),
error_summary: (status == TurnStatus::Failed)
.then(|| error_summary.and_then(sanitize_turn_status_error_summary))
.flatten(),
}
}
pub(crate) fn into_value(self) -> serde_json::Result<Value> {
serde_json::to_value(self)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub(crate) struct SessionEvent {
pub(crate) event_type: String,
pub(crate) timestamp: DateTime<Utc>,
pub(crate) session_id: String,
pub(crate) cwd: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) session_path: Option<PathBuf>,
pub(crate) payload: Value,
}
impl SessionEvent {
pub(crate) 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)
}
pub(crate) fn turn_status_payload(&self) -> Option<TurnStatusPayload> {
if self.kind() != Some(SessionEventKind::TurnStatus) {
return None;
}
let status = self
.payload
.get("status")
.cloned()
.and_then(|value| serde_json::from_value::<TurnStatus>(value).ok())?;
let assistant_text = self
.payload
.get("assistant_text")
.and_then(Value::as_str)
.map(str::to_string);
let error_summary = (status == TurnStatus::Failed)
.then(|| {
self.payload
.get("error_summary")
.and_then(Value::as_str)
.and_then(sanitize_turn_status_error_summary)
})
.flatten();
Some(TurnStatusPayload {
status,
assistant_text,
error_summary,
})
}
}
#[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 turn_status_uses_canonical_strings_and_accepts_legacy_canceled() {
for (status, wire) in [
(TurnStatus::Incomplete, "incomplete"),
(TurnStatus::Cancelled, "cancelled"),
(TurnStatus::Failed, "failed"),
(TurnStatus::CompactionRequired, "compaction_required"),
(TurnStatus::Complete, "complete"),
] {
assert_eq!(serde_json::to_value(status).unwrap(), json!(wire));
assert_eq!(
serde_json::from_value::<TurnStatus>(json!(wire)).unwrap(),
status
);
}
assert_eq!(
serde_json::from_value::<TurnStatus>(json!("canceled")).unwrap(),
TurnStatus::Cancelled
);
assert_eq!(
serde_json::to_value(TurnStatus::Cancelled).unwrap(),
json!("cancelled")
);
}
#[test]
fn failed_turn_status_payload_keeps_partial_text_and_sanitizes_error_summary() {
let raw_summary = format!(
"provider failed: api_key=secret-value\u{1b}[31m{}",
"x".repeat(TURN_STATUS_ERROR_SUMMARY_MAX_CHARS + 100)
);
let failed = TurnStatusPayload::new_with_error_summary(
TurnStatus::Failed,
Some("partial answer"),
Some(&raw_summary),
);
assert_eq!(failed.assistant_text.as_deref(), Some("partial answer"));
let summary = failed.error_summary.as_deref().expect("error summary");
assert!(summary.chars().count() <= TURN_STATUS_ERROR_SUMMARY_MAX_CHARS);
assert!(!summary.contains("secret-value"));
assert!(!summary.contains('\u{1b}'));
assert_eq!(
serde_json::to_value(&failed).unwrap()["error_summary"],
summary
);
let cancelled = TurnStatusPayload::new_with_error_summary(
TurnStatus::Cancelled,
Some("partial answer"),
Some(&raw_summary),
);
assert_eq!(cancelled.error_summary, None);
let deserialized_failed: TurnStatusPayload = serde_json::from_value(json!({
"status": "failed",
"error_summary": raw_summary.clone()
}))
.unwrap();
assert_eq!(deserialized_failed.error_summary.as_deref(), Some(summary));
let deserialized_cancelled: TurnStatusPayload = serde_json::from_value(json!({
"status": "cancelled",
"error_summary": raw_summary.clone()
}))
.unwrap();
assert_eq!(deserialized_cancelled.error_summary, None);
let temp = TempDir::new().unwrap();
let event = SessionEvent::new_kind(
SessionEventKind::TurnStatus,
"session".to_string(),
temp.path().to_path_buf(),
json!({
"status": "failed",
"assistant_text": "partial answer",
"error_summary": raw_summary.clone()
}),
);
let parsed = event.turn_status_payload().unwrap();
assert_eq!(parsed.assistant_text.as_deref(), Some("partial answer"));
assert_eq!(parsed.error_summary.as_deref(), Some(summary));
let legacy: TurnStatusPayload =
serde_json::from_value(json!({"status": "failed"})).unwrap();
assert_eq!(legacy.assistant_text, None);
assert_eq!(legacy.error_summary, None);
}
#[test]
fn turn_status_payload_preserves_optional_assistant_text() {
let incomplete: TurnStatusPayload =
serde_json::from_value(json!({"status":"incomplete"})).unwrap();
assert_eq!(incomplete.status, TurnStatus::Incomplete);
assert_eq!(incomplete.assistant_text, None);
assert_eq!(
serde_json::to_value(&incomplete).unwrap(),
json!({"status":"incomplete"})
);
let cancelled = TurnStatusPayload::new(TurnStatus::Cancelled, Some(""));
assert_eq!(
serde_json::to_value(&cancelled).unwrap(),
json!({"status":"cancelled","assistant_text":""})
);
let failed = TurnStatusPayload::new(TurnStatus::Failed, Some("partial answer"));
assert_eq!(
serde_json::to_value(&failed).unwrap(),
json!({"status":"failed","assistant_text":"partial answer"})
);
}
#[test]
fn session_event_parses_only_typed_turn_status_payloads() {
let temp = TempDir::new().unwrap();
let event = SessionEvent::new_kind(
SessionEventKind::TurnStatus,
"session".to_string(),
temp.path().to_path_buf(),
json!({"status":"canceled","assistant_text":"partial"}),
);
assert_eq!(
event.turn_status_payload(),
Some(TurnStatusPayload::new(
TurnStatus::Cancelled,
Some("partial"),
))
);
let other = SessionEvent::new(
"assistant_output",
"session".to_string(),
temp.path().to_path_buf(),
json!({"status":"failed"}),
);
assert_eq!(other.turn_status_payload(), None);
}
#[test]
fn turn_status_payload_ignores_malformed_assistant_text_but_rejects_malformed_status() {
let temp = TempDir::new().unwrap();
for (status, wire_status) in [
(TurnStatus::Cancelled, "cancelled"),
(TurnStatus::Failed, "failed"),
] {
let event = SessionEvent::new_kind(
SessionEventKind::TurnStatus,
"session".to_string(),
temp.path().to_path_buf(),
json!({"status": wire_status, "assistant_text": {"not": "text"}}),
);
assert_eq!(
event.turn_status_payload(),
Some(TurnStatusPayload::new(status, None))
);
}
let missing_text = SessionEvent::new_kind(
SessionEventKind::TurnStatus,
"session".to_string(),
temp.path().to_path_buf(),
json!({"status": "failed"}),
);
assert_eq!(
missing_text.turn_status_payload(),
Some(TurnStatusPayload::new(TurnStatus::Failed, None))
);
let malformed_status = SessionEvent::new_kind(
SessionEventKind::TurnStatus,
"session".to_string(),
temp.path().to_path_buf(),
json!({"status": "not_a_status", "assistant_text": "partial"}),
);
assert_eq!(malformed_status.turn_status_payload(), 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);
}
}