1use 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
14pub const CURRENT_SESSION_VERSION: u32 = 3;
16
17#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
19#[serde(transparent)]
20pub struct EntryId(String);
21
22impl EntryId {
23 #[must_use]
25 pub fn new(id: impl Into<String>) -> Self {
26 Self(id.into())
27 }
28
29 #[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 #[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#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
71#[serde(transparent)]
72pub struct IsoTimestamp(String);
73
74impl IsoTimestamp {
75 #[must_use]
77 pub fn now() -> Self {
78 Self::from_ms(now_ms())
79 }
80
81 #[must_use]
83 pub fn from_ms(ms: i64) -> Self {
84 Self(format_iso(ms))
85 }
86
87 #[must_use]
89 pub fn to_ms(&self) -> Option<i64> {
90 parse_iso(&self.0)
91 }
92
93 #[must_use]
95 pub fn from_text(text: impl Into<String>) -> Self {
96 Self(text.into())
97 }
98
99 #[must_use]
101 pub fn as_str(&self) -> &str {
102 &self.0
103 }
104
105 #[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(tag = "type", rename = "session", rename_all = "camelCase")]
125pub struct SessionHeader {
126 #[serde(default = "default_version")]
128 pub version: u32,
129 pub id: String,
131 #[serde(default)]
133 pub timestamp: IsoTimestamp,
134 #[serde(default)]
136 pub cwd: String,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub parent_session: Option<String>,
140}
141
142#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
144#[serde(rename_all = "camelCase")]
145pub struct SessionEntry {
146 pub id: EntryId,
148 #[serde(default)]
150 pub parent_id: Option<EntryId>,
151 #[serde(default)]
153 pub timestamp: IsoTimestamp,
154 #[serde(flatten)]
156 pub kind: EntryKind,
157}
158
159impl SessionEntry {
160 #[must_use]
162 pub const fn type_name(&self) -> &'static str {
163 self.kind.type_name()
164 }
165
166 #[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 #[must_use]
177 pub const fn is_system_message(&self) -> bool {
178 matches!(self.message(), Some(AgentMessage::System(_)))
179 }
180}
181
182#[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 Message {
192 message: AgentMessage,
194 },
195 ThinkingLevelChange {
197 #[serde(rename = "thinkingLevel")]
199 thinking_level: String,
200 },
201 ModelChange {
203 provider: String,
205 #[serde(rename = "modelId")]
207 model_id: String,
208 },
209 Usage {
211 kind: String,
213 provider: String,
215 model: String,
217 usage: Usage,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
221 note: Option<String>,
222 },
223 Compaction(CompactionEntry),
225 ContextEdit {
227 #[serde(rename = "targetId")]
229 target_id: EntryId,
230 #[serde(default)]
232 replacement: Option<ContextReplacement>,
233 },
234 BranchSummary(BranchSummaryEntry),
236 Custom {
238 #[serde(rename = "customType")]
240 custom_type: String,
241 #[serde(
243 default,
244 skip_serializing_if = "Option::is_none",
245 deserialize_with = "keep_null"
246 )]
247 data: Option<JsonValue>,
248 },
249 CustomMessage {
251 #[serde(rename = "customType")]
253 custom_type: String,
254 #[serde(default)]
256 content: UserContentField,
257 #[serde(default)]
259 display: bool,
260 #[serde(
262 default,
263 skip_serializing_if = "Option::is_none",
264 deserialize_with = "keep_null"
265 )]
266 details: Option<JsonValue>,
267 },
268 Label {
270 #[serde(rename = "targetId")]
272 target_id: EntryId,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
275 label: Option<String>,
276 },
277 SessionInfo {
279 #[serde(default, skip_serializing_if = "Option::is_none")]
281 name: Option<String>,
282 },
283}
284
285impl EntryKind {
286 #[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
307#[serde(rename_all = "camelCase")]
308pub struct CompactionEntry {
309 pub summary: String,
311 pub first_kept_entry_id: EntryId,
313 #[serde(default)]
315 pub tokens_before: u64,
316 #[serde(
318 default,
319 skip_serializing_if = "Option::is_none",
320 deserialize_with = "keep_null"
321 )]
322 pub details: Option<JsonValue>,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub usage: Option<Usage>,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub from_hook: Option<bool>,
329 #[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
354#[serde(rename_all = "camelCase")]
355pub struct BranchSummaryEntry {
356 pub from_id: EntryId,
358 #[serde(default)]
360 pub summary: String,
361 #[serde(
363 default,
364 skip_serializing_if = "Option::is_none",
365 deserialize_with = "keep_null"
366 )]
367 pub details: Option<JsonValue>,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub usage: Option<Usage>,
371 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub from_hook: Option<bool>,
374}
375
376#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
378pub struct ContextReplacement {
379 pub content: EditableContent,
381}
382
383#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
385#[serde(untagged)]
386pub enum EditableContent {
387 Text(String),
389 User(Vec<UserContent>),
391 Assistant(Vec<AssistantContent>),
393}
394
395#[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 Header(SessionHeader),
404 Entry(SessionEntry),
406}
407
408impl FileLine {
409 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 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 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}