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    /// 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}