zeph_session/event.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! The `SessionEvent` schema and its on-disk envelope.
5//!
6//! Every line appended to a session's `events.jsonl` is one JSON-encoded
7//! [`SessionEventEnvelope`]. `seq` is the source of truth for ordering (see INV-SP-1/INV-SP-2 in
8//! `specs/068-session-persistence/spec.md` §13); `ts_ms` is informational only.
9
10use serde::{Deserialize, Serialize};
11use zeph_common::memory::AnchoredSummary;
12use zeph_llm::provider::MessagePart;
13
14/// One line of a session's `events.jsonl` append-only log.
15///
16/// # Examples
17///
18/// ```
19/// use zeph_session::event::{SessionEvent, SessionEventEnvelope};
20///
21/// let envelope = SessionEventEnvelope::new(
22/// 0,
23/// None,
24/// None,
25/// SessionEvent::UserMessage { text: "hello".to_owned(), image_refs: vec![] },
26/// );
27/// let line = serde_json::to_string(&envelope).expect("serializable");
28/// let round_tripped: SessionEventEnvelope =
29/// serde_json::from_str(&line).expect("deserializable");
30/// assert_eq!(round_tripped.seq, 0);
31/// ```
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct SessionEventEnvelope {
34 /// Monotonic, gap-free, per-session sequence number starting at 0.
35 pub seq: u64,
36 /// Wall-clock milliseconds (UTC) at append time. Informational only — `seq` orders events.
37 pub ts_ms: i64,
38 /// Groups events emitted within one agent turn.
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub turn_id: Option<u64>,
41 /// Fork provenance: set only on the first event of a forked child log, referencing the
42 /// parent's `seq` at the fork point.
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub parent_seq: Option<u64>,
45 /// The tagged event payload, nested under the `kind` key (spec §4.2).
46 pub kind: SessionEvent,
47}
48
49impl SessionEventEnvelope {
50 /// Construct an envelope with `ts_ms` set to the current wall-clock time.
51 #[must_use]
52 pub fn new(
53 seq: u64,
54 turn_id: Option<u64>,
55 parent_seq: Option<u64>,
56 kind: SessionEvent,
57 ) -> Self {
58 Self {
59 seq,
60 ts_ms: now_ms(),
61 turn_id,
62 parent_seq,
63 kind,
64 }
65 }
66}
67
68/// Current wall-clock time in milliseconds since the Unix epoch, saturating on overflow.
69#[must_use]
70pub fn now_ms() -> i64 {
71 let dur = std::time::SystemTime::now()
72 .duration_since(std::time::UNIX_EPOCH)
73 .unwrap_or_default();
74 i64::try_from(dur.as_millis()).unwrap_or(i64::MAX)
75}
76
77/// The kind of a persisted conversation-session event.
78///
79/// See `specs/068-session-persistence/spec.md` §4.3 for the full contract. `MessagePart` is
80/// reused from [`zeph_llm::provider`] and `AnchoredSummary` from [`zeph_common::memory`] — this
81/// enum MUST NOT redefine either.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(tag = "type", rename_all = "snake_case")]
84pub enum SessionEvent {
85 /// First event of a session's log; also written as the header line of a forked child log.
86 SessionStarted {
87 session_id: String,
88 cwd: String,
89 provider_name: String,
90 model: String,
91 /// `(parent_session_id, parent_seq_at_fork)`, set only for forked sessions.
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 forked_from: Option<(String, u64)>,
94 },
95 /// A user turn.
96 UserMessage {
97 text: String,
98 /// Content-hash refs into the session's `blobs/` directory.
99 #[serde(default)]
100 image_refs: Vec<String>,
101 },
102 /// An assistant turn.
103 AssistantMessage { parts: Vec<MessagePart> },
104 /// A model-initiated tool invocation.
105 ToolCall {
106 id: String,
107 name: String,
108 input: serde_json::Value,
109 },
110 /// The result of a [`SessionEvent::ToolCall`]. Replay never re-executes tools; it folds this
111 /// recorded output.
112 ToolResult {
113 id: String,
114 name: String,
115 output: String,
116 is_error: bool,
117 duration_ms: u64,
118 },
119 /// Durable, replayable condensation of a `seq` range (distinct from live in-memory
120 /// compaction; see spec §8.1).
121 Condensation {
122 /// `[inclusive, inclusive]` seq range replaced by `summary`.
123 replaced_seq_range: (u64, u64),
124 summary: AnchoredSummary,
125 tokens_before: u32,
126 tokens_after: u32,
127 },
128 /// Recorded when live hard-compaction fires during a turn, so replay can fold the same
129 /// prune/summary deterministically.
130 Compaction {
131 tier: CompactionTier,
132 cleared_count: u32,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 summary: Option<AnchoredSummary>,
135 },
136 /// Non-destructive provenance record appended to the **parent** log when a child session is
137 /// forked from it.
138 ForkPoint { new_session_id: String },
139 /// The active provider/model changed mid-session.
140 ModelChanged {
141 provider_name: String,
142 model: String,
143 },
144 /// The session ended; `reason` is one of `user_quit` | `idle_ttl` | `shutdown` | `error`.
145 SessionEnded { reason: String },
146}
147
148/// Which compaction threshold fired for a [`SessionEvent::Compaction`] event.
149///
150/// Mirrors `zeph_context::manager::CompactionTier` (soft 70% / hard 90% budget thresholds) but is
151/// redefined here rather than imported: `zeph-context` is a context-assembly crate the session
152/// event schema should not need to pull in just for this one enum, and the two enums are kept in
153/// sync manually since compaction tiers change rarely.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum CompactionTier {
157 /// Soft threshold (~70% of budget): a light prune.
158 Soft,
159 /// Hard threshold (~90% of budget): an aggressive prune.
160 Hard,
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn envelope_round_trips_through_json() {
169 let envelope = SessionEventEnvelope::new(
170 5,
171 Some(2),
172 None,
173 SessionEvent::ToolResult {
174 id: "t1".to_owned(),
175 name: "shell".to_owned(),
176 output: "ok".to_owned(),
177 is_error: false,
178 duration_ms: 12,
179 },
180 );
181 let json = serde_json::to_string(&envelope).unwrap();
182 let back: SessionEventEnvelope = serde_json::from_str(&json).unwrap();
183 assert_eq!(back.seq, 5);
184 assert_eq!(back.turn_id, Some(2));
185 assert!(back.parent_seq.is_none());
186 assert!(matches!(back.kind, SessionEvent::ToolResult { .. }));
187 }
188
189 #[test]
190 fn session_started_forked_from_round_trips() {
191 let envelope = SessionEventEnvelope::new(
192 0,
193 None,
194 Some(41),
195 SessionEvent::SessionStarted {
196 session_id: "child".to_owned(),
197 cwd: "/tmp".to_owned(),
198 provider_name: "claude".to_owned(),
199 model: "opus".to_owned(),
200 forked_from: Some(("parent".to_owned(), 41)),
201 },
202 );
203 let json = serde_json::to_string(&envelope).unwrap();
204 let back: SessionEventEnvelope = serde_json::from_str(&json).unwrap();
205 let SessionEvent::SessionStarted { forked_from, .. } = back.kind else {
206 panic!("expected SessionStarted");
207 };
208 assert_eq!(forked_from, Some(("parent".to_owned(), 41)));
209 }
210}