Skip to main content

oino_session/
entry.rs

1//! Session v3 line types: [`SessionHeader`], [`SessionEntry`] and [`EntryKind`].
2
3use std::fmt;
4
5use oino_ai::json::keep_null;
6use oino_ai::{
7    AgentMessage, AssistantContent, JsonValue, SystemMessage, Usage, UserContent, UserContentField,
8};
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12use crate::time::{format_iso, now_ms, parse_iso};
13
14/// Current session format version.
15pub const CURRENT_SESSION_VERSION: u32 = 3;
16
17/// Entry id: 8 lowercase hex chars normally; any string is accepted on read.
18#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
19#[serde(transparent)]
20pub struct EntryId(String);
21
22impl EntryId {
23    /// Wraps an id string as-is.
24    #[must_use]
25    pub fn new(id: impl Into<String>) -> Self {
26        Self(id.into())
27    }
28
29    /// First 8 hex chars of a random UUID, retried up to 100 times against
30    /// `taken`, then a full UUID.
31    #[must_use]
32    pub fn generate(taken: impl Fn(&str) -> bool) -> Self {
33        for _ in 0..100 {
34            let uuid = Uuid::new_v4().simple().to_string();
35            let short = &uuid[..8];
36            if !taken(short) {
37                return Self(short.to_owned());
38            }
39        }
40        Self(Uuid::new_v4().hyphenated().to_string())
41    }
42
43    /// The id text.
44    #[must_use]
45    pub fn as_str(&self) -> &str {
46        &self.0
47    }
48}
49
50impl fmt::Display for EntryId {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        f.write_str(&self.0)
53    }
54}
55
56impl From<&str> for EntryId {
57    fn from(id: &str) -> Self {
58        Self(id.to_owned())
59    }
60}
61
62impl From<String> for EntryId {
63    fn from(id: String) -> Self {
64        Self(id)
65    }
66}
67
68/// ISO-8601 UTC timestamp with millisecond precision and `Z` (JS `toISOString`). The original
69/// text is preserved on round trips.
70#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
71#[serde(transparent)]
72pub struct IsoTimestamp(String);
73
74impl IsoTimestamp {
75    /// Current time.
76    #[must_use]
77    pub fn now() -> Self {
78        Self::from_ms(now_ms())
79    }
80
81    /// From Unix milliseconds.
82    #[must_use]
83    pub fn from_ms(ms: i64) -> Self {
84        Self(format_iso(ms))
85    }
86
87    /// Unix milliseconds, `None` when the text is not a parseable date (JS `NaN`).
88    #[must_use]
89    pub fn to_ms(&self) -> Option<i64> {
90        parse_iso(&self.0)
91    }
92
93    /// Wraps text as-is.
94    #[must_use]
95    pub fn from_text(text: impl Into<String>) -> Self {
96        Self(text.into())
97    }
98
99    /// The timestamp text.
100    #[must_use]
101    pub fn as_str(&self) -> &str {
102        &self.0
103    }
104
105    /// File-name form: `:` and `.` replaced by `-` (`2026-10-26T03-00-07-623Z`).
106    #[must_use]
107    pub fn file_stem(&self) -> String {
108        self.0.replace([':', '.'], "-")
109    }
110}
111
112impl fmt::Display for IsoTimestamp {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        f.write_str(&self.0)
115    }
116}
117
118const fn default_version() -> u32 {
119    1
120}
121
122/// First line of a session file (`"type":"session"`).
123#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(tag = "type", rename = "session", rename_all = "camelCase")]
125pub struct SessionHeader {
126    /// Format version (missing ⇒ 1; 3 after migration).
127    #[serde(default = "default_version")]
128    pub version: u32,
129    /// Session id (`UUIDv7` by default).
130    pub id: String,
131    /// Creation time.
132    #[serde(default)]
133    pub timestamp: IsoTimestamp,
134    /// Working directory the session belongs to.
135    #[serde(default)]
136    pub cwd: String,
137    /// Source session file for forks/clones.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub parent_session: Option<String>,
140}
141
142/// One tree entry (every line after the header).
143#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
144#[serde(rename_all = "camelCase")]
145pub struct SessionEntry {
146    /// Entry id.
147    pub id: EntryId,
148    /// Parent entry; `null` for roots (always serialised).
149    #[serde(default)]
150    pub parent_id: Option<EntryId>,
151    /// Creation time.
152    #[serde(default)]
153    pub timestamp: IsoTimestamp,
154    /// Type-specific payload (`type` tag).
155    #[serde(flatten)]
156    pub kind: EntryKind,
157}
158
159impl SessionEntry {
160    /// `type` string of this entry.
161    #[must_use]
162    pub const fn type_name(&self) -> &'static str {
163        self.kind.type_name()
164    }
165
166    /// The message of a `message` entry.
167    #[must_use]
168    pub const fn message(&self) -> Option<&AgentMessage> {
169        match &self.kind {
170            EntryKind::Message { message } => Some(message),
171            _ => None,
172        }
173    }
174
175    /// `true` for `message` entries whose role is `system`.
176    #[must_use]
177    pub const fn is_system_message(&self) -> bool {
178        matches!(self.message(), Some(AgentMessage::System(_)))
179    }
180}
181
182/// Entry payloads (`"type"` discriminant, snake case).
183#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
184#[serde(tag = "type", rename_all = "snake_case")]
185#[allow(
186    clippy::large_enum_variant,
187    reason = "variants mirror the entry shapes; entries are stored by value in one Vec"
188)]
189pub enum EntryKind {
190    /// Conversation message.
191    Message {
192        /// The message.
193        message: AgentMessage,
194    },
195    /// Thinking level change (free string; parse with `ThinkingLevel::from_str`).
196    ThinkingLevelChange {
197        /// New level.
198        #[serde(rename = "thinkingLevel")]
199        thinking_level: String,
200    },
201    /// Model change.
202    ModelChange {
203        /// Provider id.
204        provider: String,
205        /// Model id.
206        #[serde(rename = "modelId")]
207        model_id: String,
208    },
209    /// Usage outside assistant messages (e.g. `cache_warm`).
210    Usage {
211        /// Operation kind (unknown kinds are normal usage).
212        kind: String,
213        /// Provider id.
214        provider: String,
215        /// Model id.
216        model: String,
217        /// Usage.
218        usage: Usage,
219        /// Free-form note.
220        #[serde(default, skip_serializing_if = "Option::is_none")]
221        note: Option<String>,
222    },
223    /// Compaction.
224    Compaction(CompactionEntry),
225    /// Append-only context edit of an earlier entry.
226    ContextEdit {
227        /// Edited entry.
228        #[serde(rename = "targetId")]
229        target_id: EntryId,
230        /// Replacement content, `null` (always serialised) to omit the target.
231        #[serde(default)]
232        replacement: Option<ContextReplacement>,
233    },
234    /// Branch summary.
235    BranchSummary(BranchSummaryEntry),
236    /// Extension state (never in context).
237    Custom {
238        /// Extension-defined type.
239        #[serde(rename = "customType")]
240        custom_type: String,
241        /// Payload.
242        #[serde(
243            default,
244            skip_serializing_if = "Option::is_none",
245            deserialize_with = "keep_null"
246        )]
247        data: Option<JsonValue>,
248    },
249    /// Extension message that participates in context.
250    CustomMessage {
251        /// Extension-defined type.
252        #[serde(rename = "customType")]
253        custom_type: String,
254        /// String or blocks.
255        #[serde(default)]
256        content: UserContentField,
257        /// Whether frontends render it.
258        #[serde(default)]
259        display: bool,
260        /// Metadata (not sent to the LLM).
261        #[serde(
262            default,
263            skip_serializing_if = "Option::is_none",
264            deserialize_with = "keep_null"
265        )]
266        details: Option<JsonValue>,
267    },
268    /// Label set/cleared on an entry.
269    Label {
270        /// Labelled entry.
271        #[serde(rename = "targetId")]
272        target_id: EntryId,
273        /// Label; absent/empty clears.
274        #[serde(default, skip_serializing_if = "Option::is_none")]
275        label: Option<String>,
276    },
277    /// Session metadata (display name).
278    SessionInfo {
279        /// Session name.
280        #[serde(default, skip_serializing_if = "Option::is_none")]
281        name: Option<String>,
282    },
283}
284
285impl EntryKind {
286    /// `type` string.
287    #[must_use]
288    pub const fn type_name(&self) -> &'static str {
289        match self {
290            Self::Message { .. } => "message",
291            Self::ThinkingLevelChange { .. } => "thinking_level_change",
292            Self::ModelChange { .. } => "model_change",
293            Self::Usage { .. } => "usage",
294            Self::Compaction(_) => "compaction",
295            Self::ContextEdit { .. } => "context_edit",
296            Self::BranchSummary(_) => "branch_summary",
297            Self::Custom { .. } => "custom",
298            Self::CustomMessage { .. } => "custom_message",
299            Self::Label { .. } => "label",
300            Self::SessionInfo { .. } => "session_info",
301        }
302    }
303}
304
305/// `compaction` payload.
306#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
307#[serde(rename_all = "camelCase")]
308pub struct CompactionEntry {
309    /// Summary text.
310    pub summary: String,
311    /// First retained entry; the compaction's own id means "retain none".
312    pub first_kept_entry_id: EntryId,
313    /// Context tokens before compaction.
314    #[serde(default)]
315    pub tokens_before: u64,
316    /// Implementation data (e.g. `{readFiles, modifiedFiles}`).
317    #[serde(
318        default,
319        skip_serializing_if = "Option::is_none",
320        deserialize_with = "keep_null"
321    )]
322    pub details: Option<JsonValue>,
323    /// Usage of the summary request.
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub usage: Option<Usage>,
326    /// `true` when produced by an extension.
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub from_hook: Option<bool>,
329    /// Complete prompt/tool checkpoint at the boundary (serialised with `"role":"system"`).
330    #[serde(
331        default,
332        skip_serializing_if = "Option::is_none",
333        serialize_with = "system_with_role"
334    )]
335    pub system_message: Option<SystemMessage>,
336}
337
338#[allow(
339    clippy::ref_option,
340    reason = "signature required by serde(serialize_with)"
341)]
342fn system_with_role<S: serde::Serializer>(
343    value: &Option<SystemMessage>,
344    serializer: S,
345) -> Result<S::Ok, S::Error> {
346    value
347        .clone()
348        .map(oino_ai::Message::System)
349        .serialize(serializer)
350}
351
352/// `branch_summary` payload.
353#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
354#[serde(rename_all = "camelCase")]
355pub struct BranchSummaryEntry {
356    /// Previous leaf whose path was summarised (`"root"` when there was none).
357    pub from_id: EntryId,
358    /// Summary text.
359    #[serde(default)]
360    pub summary: String,
361    /// File tracking or extension data.
362    #[serde(
363        default,
364        skip_serializing_if = "Option::is_none",
365        deserialize_with = "keep_null"
366    )]
367    pub details: Option<JsonValue>,
368    /// Usage of the summary request.
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub usage: Option<Usage>,
371    /// `true` when produced by an extension.
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub from_hook: Option<bool>,
374}
375
376/// `context_edit.replacement` object.
377#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
378pub struct ContextReplacement {
379    /// New content.
380    pub content: EditableContent,
381}
382
383/// Replacement content: a string or a block array.
384#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
385#[serde(untagged)]
386pub enum EditableContent {
387    /// String form.
388    Text(String),
389    /// Text/image blocks.
390    User(Vec<UserContent>),
391    /// Assistant blocks (text/thinking/tool calls).
392    Assistant(Vec<AssistantContent>),
393}
394
395/// One parsed line of a session file.
396#[allow(
397    clippy::large_enum_variant,
398    reason = "short-lived parse result; boxing would only add an allocation per line"
399)]
400#[derive(Clone, Debug, PartialEq)]
401pub enum FileLine {
402    /// `"type":"session"` header.
403    Header(SessionHeader),
404    /// Tree entry.
405    Entry(SessionEntry),
406}
407
408impl FileLine {
409    /// Parses one JSON line (header when `type == "session"`).
410    ///
411    /// # Errors
412    ///
413    /// Returns the serde error when the line is not a valid header or entry.
414    pub fn parse(line: &str) -> Result<Self, serde_json::Error> {
415        let value: JsonValue = serde_json::from_str(line)?;
416        Self::from_value(value)
417    }
418
419    /// Like [`FileLine::parse`] from an already parsed value.
420    ///
421    /// # Errors
422    ///
423    /// Returns the serde error when the value is not a valid header or entry.
424    pub fn from_value(value: JsonValue) -> Result<Self, serde_json::Error> {
425        if value.get("type").and_then(JsonValue::as_str) == Some("session") {
426            serde_json::from_value(value).map(Self::Header)
427        } else {
428            serde_json::from_value(value).map(Self::Entry)
429        }
430    }
431
432    /// Serialises to one JSON line (no trailing newline).
433    ///
434    /// # Errors
435    ///
436    /// Propagates serde errors (not expected for these types).
437    pub fn to_line(&self) -> Result<String, serde_json::Error> {
438        match self {
439            Self::Header(header) => serde_json::to_string(header),
440            Self::Entry(entry) => serde_json::to_string(entry),
441        }
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use serde_json::json;
449
450    #[test]
451    fn header_has_type_first_and_requires_session_tag() {
452        let header = SessionHeader {
453            version: 3,
454            id: "u".into(),
455            timestamp: IsoTimestamp::from_ms(0),
456            cwd: "/p".into(),
457            parent_session: None,
458        };
459        let line = serde_json::to_string(&header).unwrap();
460        assert!(
461            line.starts_with("{\"type\":\"session\",\"version\":3"),
462            "{line}"
463        );
464        let v1: SessionHeader =
465            serde_json::from_value(json!({"type":"session","id":"x","timestamp":"t","cwd":"/"}))
466                .unwrap();
467        assert_eq!(v1.version, 1);
468    }
469
470    #[test]
471    fn generated_ids_are_short_hex() {
472        let id = EntryId::generate(|_| false);
473        assert_eq!(id.as_str().len(), 8);
474        assert!(id.as_str().bytes().all(|b| b.is_ascii_hexdigit()));
475        assert_eq!(EntryId::generate(|_| true).as_str().len(), 36);
476    }
477
478    #[test]
479    fn null_fields_are_always_present() {
480        let entry = SessionEntry {
481            id: "a".into(),
482            parent_id: None,
483            timestamp: IsoTimestamp::from_ms(0),
484            kind: EntryKind::ContextEdit {
485                target_id: "b".into(),
486                replacement: None,
487            },
488        };
489        assert_eq!(
490            serde_json::to_value(&entry).unwrap(),
491            json!({"id":"a","parentId":null,"timestamp":"1970-01-01T00:00:00.000Z","type":"context_edit","targetId":"b","replacement":null})
492        );
493    }
494}