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 /// Keyed-BLAKE3 hash chain link (hex-encoded), binding this event's content and the
48 /// previous event's hash (issue #6360). `None` on every event means this log predates the
49 /// feature or history-chain verification is disabled for this process (legacy,
50 /// auto-trusted-once per spec-069 FR-006). Additive field: `#[serde(default)]` means an
51 /// older reader/writer that doesn't know this field ignores it, and legacy logs without it
52 /// parse unchanged.
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub chain: Option<String>,
55}
56
57impl SessionEventEnvelope {
58 /// Construct an envelope with `ts_ms` set to the current wall-clock time.
59 #[must_use]
60 pub fn new(
61 seq: u64,
62 turn_id: Option<u64>,
63 parent_seq: Option<u64>,
64 kind: SessionEvent,
65 ) -> Self {
66 Self {
67 seq,
68 ts_ms: now_ms(),
69 turn_id,
70 parent_seq,
71 kind,
72 chain: None,
73 }
74 }
75}
76
77/// Current wall-clock time in milliseconds since the Unix epoch, saturating on overflow.
78#[must_use]
79pub fn now_ms() -> i64 {
80 let dur = std::time::SystemTime::now()
81 .duration_since(std::time::UNIX_EPOCH)
82 .unwrap_or_default();
83 i64::try_from(dur.as_millis()).unwrap_or(i64::MAX)
84}
85
86/// The kind of a persisted conversation-session event.
87///
88/// See `specs/068-session-persistence/spec.md` §4.3 for the full contract. `MessagePart` is
89/// reused from [`zeph_llm::provider`] and `AnchoredSummary` from [`zeph_common::memory`] — this
90/// enum MUST NOT redefine either.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92#[serde(tag = "type", rename_all = "snake_case")]
93pub enum SessionEvent {
94 /// First event of a session's log; also written as the header line of a forked child log.
95 SessionStarted {
96 session_id: String,
97 cwd: String,
98 provider_name: String,
99 model: String,
100 /// `(parent_session_id, parent_seq_at_fork)`, set only for forked sessions.
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 forked_from: Option<(String, u64)>,
103 },
104 /// A user turn.
105 UserMessage {
106 text: String,
107 /// Content-hash refs into the session's `blobs/` directory.
108 #[serde(default)]
109 image_refs: Vec<String>,
110 },
111 /// An assistant turn.
112 AssistantMessage { parts: Vec<MessagePart> },
113 /// A model-initiated tool invocation.
114 ToolCall {
115 id: String,
116 name: String,
117 input: serde_json::Value,
118 },
119 /// The result of a [`SessionEvent::ToolCall`]. Replay never re-executes tools; it folds this
120 /// recorded output.
121 ToolResult {
122 id: String,
123 name: String,
124 output: String,
125 is_error: bool,
126 duration_ms: u64,
127 },
128 /// Durable, replayable condensation of a `seq` range (distinct from live in-memory
129 /// compaction; see spec §8.1).
130 Condensation {
131 /// `[inclusive, inclusive]` seq range replaced by `summary`.
132 replaced_seq_range: (u64, u64),
133 summary: AnchoredSummary,
134 tokens_before: u32,
135 tokens_after: u32,
136 },
137 /// Recorded when live hard-compaction fires during a turn, so replay can fold the same
138 /// prune/summary deterministically.
139 Compaction {
140 tier: CompactionTier,
141 cleared_count: u32,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 summary: Option<AnchoredSummary>,
144 },
145 /// Non-destructive provenance record appended to the **parent** log when a child session is
146 /// forked from it.
147 ForkPoint { new_session_id: String },
148 /// The active provider/model changed mid-session.
149 ModelChanged {
150 provider_name: String,
151 model: String,
152 },
153 /// The session ended; `reason` is one of `user_quit` | `idle_ttl` | `shutdown` | `error`.
154 SessionEnded { reason: String },
155}
156
157/// Which compaction threshold fired for a [`SessionEvent::Compaction`] event.
158///
159/// Mirrors `zeph_context::manager::CompactionTier` (soft 70% / hard 90% budget thresholds) but is
160/// redefined here rather than imported: `zeph-context` is a context-assembly crate the session
161/// event schema should not need to pull in just for this one enum, and the two enums are kept in
162/// sync manually since compaction tiers change rarely.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(rename_all = "snake_case")]
165pub enum CompactionTier {
166 /// Soft threshold (~70% of budget): a light prune.
167 Soft,
168 /// Hard threshold (~90% of budget): an aggressive prune.
169 Hard,
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn envelope_round_trips_through_json() {
178 let envelope = SessionEventEnvelope::new(
179 5,
180 Some(2),
181 None,
182 SessionEvent::ToolResult {
183 id: "t1".to_owned(),
184 name: "shell".to_owned(),
185 output: "ok".to_owned(),
186 is_error: false,
187 duration_ms: 12,
188 },
189 );
190 let json = serde_json::to_string(&envelope).unwrap();
191 let back: SessionEventEnvelope = serde_json::from_str(&json).unwrap();
192 assert_eq!(back.seq, 5);
193 assert_eq!(back.turn_id, Some(2));
194 assert!(back.parent_seq.is_none());
195 assert!(matches!(back.kind, SessionEvent::ToolResult { .. }));
196 }
197
198 #[test]
199 fn session_started_forked_from_round_trips() {
200 let envelope = SessionEventEnvelope::new(
201 0,
202 None,
203 Some(41),
204 SessionEvent::SessionStarted {
205 session_id: "child".to_owned(),
206 cwd: "/tmp".to_owned(),
207 provider_name: "claude".to_owned(),
208 model: "opus".to_owned(),
209 forked_from: Some(("parent".to_owned(), 41)),
210 },
211 );
212 let json = serde_json::to_string(&envelope).unwrap();
213 let back: SessionEventEnvelope = serde_json::from_str(&json).unwrap();
214 let SessionEvent::SessionStarted { forked_from, .. } = back.kind else {
215 panic!("expected SessionStarted");
216 };
217 assert_eq!(forked_from, Some(("parent".to_owned(), 41)));
218 }
219}