use serde::{Deserialize, Serialize};
use zeph_common::memory::AnchoredSummary;
use zeph_llm::provider::MessagePart;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionEventEnvelope {
pub seq: u64,
pub ts_ms: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn_id: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_seq: Option<u64>,
pub kind: SessionEvent,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chain: Option<String>,
}
impl SessionEventEnvelope {
#[must_use]
pub fn new(
seq: u64,
turn_id: Option<u64>,
parent_seq: Option<u64>,
kind: SessionEvent,
) -> Self {
Self {
seq,
ts_ms: now_ms(),
turn_id,
parent_seq,
kind,
chain: None,
}
}
}
#[must_use]
pub fn now_ms() -> i64 {
let dur = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
i64::try_from(dur.as_millis()).unwrap_or(i64::MAX)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SessionEvent {
SessionStarted {
session_id: String,
cwd: String,
provider_name: String,
model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
forked_from: Option<(String, u64)>,
},
UserMessage {
text: String,
#[serde(default)]
image_refs: Vec<String>,
},
AssistantMessage { parts: Vec<MessagePart> },
ToolCall {
id: String,
name: String,
input: serde_json::Value,
},
ToolResult {
id: String,
name: String,
output: String,
is_error: bool,
duration_ms: u64,
},
Condensation {
replaced_seq_range: (u64, u64),
summary: AnchoredSummary,
tokens_before: u32,
tokens_after: u32,
},
Compaction {
tier: CompactionTier,
cleared_count: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
summary: Option<AnchoredSummary>,
},
ForkPoint { new_session_id: String },
ModelChanged {
provider_name: String,
model: String,
},
SessionEnded { reason: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CompactionTier {
Soft,
Hard,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn envelope_round_trips_through_json() {
let envelope = SessionEventEnvelope::new(
5,
Some(2),
None,
SessionEvent::ToolResult {
id: "t1".to_owned(),
name: "shell".to_owned(),
output: "ok".to_owned(),
is_error: false,
duration_ms: 12,
},
);
let json = serde_json::to_string(&envelope).unwrap();
let back: SessionEventEnvelope = serde_json::from_str(&json).unwrap();
assert_eq!(back.seq, 5);
assert_eq!(back.turn_id, Some(2));
assert!(back.parent_seq.is_none());
assert!(matches!(back.kind, SessionEvent::ToolResult { .. }));
}
#[test]
fn session_started_forked_from_round_trips() {
let envelope = SessionEventEnvelope::new(
0,
None,
Some(41),
SessionEvent::SessionStarted {
session_id: "child".to_owned(),
cwd: "/tmp".to_owned(),
provider_name: "claude".to_owned(),
model: "opus".to_owned(),
forked_from: Some(("parent".to_owned(), 41)),
},
);
let json = serde_json::to_string(&envelope).unwrap();
let back: SessionEventEnvelope = serde_json::from_str(&json).unwrap();
let SessionEvent::SessionStarted { forked_from, .. } = back.kind else {
panic!("expected SessionStarted");
};
assert_eq!(forked_from, Some(("parent".to_owned(), 41)));
}
}