Skip to main content

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}