use std::fmt;
use oino_ai::json::keep_null;
use oino_ai::{
AgentMessage, AssistantContent, JsonValue, SystemMessage, Usage, UserContent, UserContentField,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::time::{format_iso, now_ms, parse_iso};
pub const CURRENT_SESSION_VERSION: u32 = 3;
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct EntryId(String);
impl EntryId {
#[must_use]
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
#[must_use]
pub fn generate(taken: impl Fn(&str) -> bool) -> Self {
for _ in 0..100 {
let uuid = Uuid::new_v4().simple().to_string();
let short = &uuid[..8];
if !taken(short) {
return Self(short.to_owned());
}
}
Self(Uuid::new_v4().hyphenated().to_string())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for EntryId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<&str> for EntryId {
fn from(id: &str) -> Self {
Self(id.to_owned())
}
}
impl From<String> for EntryId {
fn from(id: String) -> Self {
Self(id)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct IsoTimestamp(String);
impl IsoTimestamp {
#[must_use]
pub fn now() -> Self {
Self::from_ms(now_ms())
}
#[must_use]
pub fn from_ms(ms: i64) -> Self {
Self(format_iso(ms))
}
#[must_use]
pub fn to_ms(&self) -> Option<i64> {
parse_iso(&self.0)
}
#[must_use]
pub fn from_text(text: impl Into<String>) -> Self {
Self(text.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn file_stem(&self) -> String {
self.0.replace([':', '.'], "-")
}
}
impl fmt::Display for IsoTimestamp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
const fn default_version() -> u32 {
1
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename = "session", rename_all = "camelCase")]
pub struct SessionHeader {
#[serde(default = "default_version")]
pub version: u32,
pub id: String,
#[serde(default)]
pub timestamp: IsoTimestamp,
#[serde(default)]
pub cwd: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_session: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionEntry {
pub id: EntryId,
#[serde(default)]
pub parent_id: Option<EntryId>,
#[serde(default)]
pub timestamp: IsoTimestamp,
#[serde(flatten)]
pub kind: EntryKind,
}
impl SessionEntry {
#[must_use]
pub const fn type_name(&self) -> &'static str {
self.kind.type_name()
}
#[must_use]
pub const fn message(&self) -> Option<&AgentMessage> {
match &self.kind {
EntryKind::Message { message } => Some(message),
_ => None,
}
}
#[must_use]
pub const fn is_system_message(&self) -> bool {
matches!(self.message(), Some(AgentMessage::System(_)))
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[allow(
clippy::large_enum_variant,
reason = "variants mirror the entry shapes; entries are stored by value in one Vec"
)]
pub enum EntryKind {
Message {
message: AgentMessage,
},
ThinkingLevelChange {
#[serde(rename = "thinkingLevel")]
thinking_level: String,
},
ModelChange {
provider: String,
#[serde(rename = "modelId")]
model_id: String,
},
Usage {
kind: String,
provider: String,
model: String,
usage: Usage,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
Compaction(CompactionEntry),
ContextEdit {
#[serde(rename = "targetId")]
target_id: EntryId,
#[serde(default)]
replacement: Option<ContextReplacement>,
},
BranchSummary(BranchSummaryEntry),
Custom {
#[serde(rename = "customType")]
custom_type: String,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "keep_null"
)]
data: Option<JsonValue>,
},
CustomMessage {
#[serde(rename = "customType")]
custom_type: String,
#[serde(default)]
content: UserContentField,
#[serde(default)]
display: bool,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "keep_null"
)]
details: Option<JsonValue>,
},
Label {
#[serde(rename = "targetId")]
target_id: EntryId,
#[serde(default, skip_serializing_if = "Option::is_none")]
label: Option<String>,
},
SessionInfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
}
impl EntryKind {
#[must_use]
pub const fn type_name(&self) -> &'static str {
match self {
Self::Message { .. } => "message",
Self::ThinkingLevelChange { .. } => "thinking_level_change",
Self::ModelChange { .. } => "model_change",
Self::Usage { .. } => "usage",
Self::Compaction(_) => "compaction",
Self::ContextEdit { .. } => "context_edit",
Self::BranchSummary(_) => "branch_summary",
Self::Custom { .. } => "custom",
Self::CustomMessage { .. } => "custom_message",
Self::Label { .. } => "label",
Self::SessionInfo { .. } => "session_info",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CompactionEntry {
pub summary: String,
pub first_kept_entry_id: EntryId,
#[serde(default)]
pub tokens_before: u64,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "keep_null"
)]
pub details: Option<JsonValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from_hook: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
serialize_with = "system_with_role"
)]
pub system_message: Option<SystemMessage>,
}
#[allow(
clippy::ref_option,
reason = "signature required by serde(serialize_with)"
)]
fn system_with_role<S: serde::Serializer>(
value: &Option<SystemMessage>,
serializer: S,
) -> Result<S::Ok, S::Error> {
value
.clone()
.map(oino_ai::Message::System)
.serialize(serializer)
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BranchSummaryEntry {
pub from_id: EntryId,
#[serde(default)]
pub summary: String,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "keep_null"
)]
pub details: Option<JsonValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from_hook: Option<bool>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ContextReplacement {
pub content: EditableContent,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum EditableContent {
Text(String),
User(Vec<UserContent>),
Assistant(Vec<AssistantContent>),
}
#[allow(
clippy::large_enum_variant,
reason = "short-lived parse result; boxing would only add an allocation per line"
)]
#[derive(Clone, Debug, PartialEq)]
pub enum FileLine {
Header(SessionHeader),
Entry(SessionEntry),
}
impl FileLine {
pub fn parse(line: &str) -> Result<Self, serde_json::Error> {
let value: JsonValue = serde_json::from_str(line)?;
Self::from_value(value)
}
pub fn from_value(value: JsonValue) -> Result<Self, serde_json::Error> {
if value.get("type").and_then(JsonValue::as_str) == Some("session") {
serde_json::from_value(value).map(Self::Header)
} else {
serde_json::from_value(value).map(Self::Entry)
}
}
pub fn to_line(&self) -> Result<String, serde_json::Error> {
match self {
Self::Header(header) => serde_json::to_string(header),
Self::Entry(entry) => serde_json::to_string(entry),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn header_has_type_first_and_requires_session_tag() {
let header = SessionHeader {
version: 3,
id: "u".into(),
timestamp: IsoTimestamp::from_ms(0),
cwd: "/p".into(),
parent_session: None,
};
let line = serde_json::to_string(&header).unwrap();
assert!(
line.starts_with("{\"type\":\"session\",\"version\":3"),
"{line}"
);
let v1: SessionHeader =
serde_json::from_value(json!({"type":"session","id":"x","timestamp":"t","cwd":"/"}))
.unwrap();
assert_eq!(v1.version, 1);
}
#[test]
fn generated_ids_are_short_hex() {
let id = EntryId::generate(|_| false);
assert_eq!(id.as_str().len(), 8);
assert!(id.as_str().bytes().all(|b| b.is_ascii_hexdigit()));
assert_eq!(EntryId::generate(|_| true).as_str().len(), 36);
}
#[test]
fn null_fields_are_always_present() {
let entry = SessionEntry {
id: "a".into(),
parent_id: None,
timestamp: IsoTimestamp::from_ms(0),
kind: EntryKind::ContextEdit {
target_id: "b".into(),
replacement: None,
},
};
assert_eq!(
serde_json::to_value(&entry).unwrap(),
json!({"id":"a","parentId":null,"timestamp":"1970-01-01T00:00:00.000Z","type":"context_edit","targetId":"b","replacement":null})
);
}
}