use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const FORMAT_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SyntheticReason {
ProjectInstructions,
SystemReminder,
Steer,
CompactionSummary,
SubagentResult,
DoomLoopNudge,
RetryFeedback,
Moim,
Memory,
SubdirInstructions,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Item {
System {
text: String,
},
User {
text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
synthetic: Option<SyntheticReason>,
},
Assistant {
blocks: Vec<Value>,
},
ToolResults {
results: Vec<ToolResultItem>,
},
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolResultItem {
pub tool_use_id: String,
pub content: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub is_error: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ToolUse {
pub id: String,
pub name: String,
pub input: Value,
}
pub fn assistant_text(blocks: &[Value]) -> String {
blocks
.iter()
.filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
.filter_map(|b| b.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("")
}
pub fn assistant_tool_uses(blocks: &[Value]) -> Vec<ToolUse> {
blocks
.iter()
.filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_use"))
.filter_map(|b| {
Some(ToolUse {
id: b.get("id")?.as_str()?.to_string(),
name: b.get("name")?.as_str()?.to_string(),
input: b.get("input").cloned().unwrap_or(Value::Null),
})
})
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StopReason {
EndTurn,
MaxTokens,
ToolUse,
StopSequence,
PauseTurn,
Refusal,
#[serde(other)]
Other,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenUsage {
#[serde(default)]
pub input_tokens: u64,
#[serde(default)]
pub output_tokens: u64,
#[serde(default)]
pub cache_read_input_tokens: u64,
#[serde(default)]
pub cache_creation_input_tokens: u64,
}
impl std::ops::AddAssign for TokenUsage {
fn add_assign(&mut self, rhs: Self) {
self.input_tokens += rhs.input_tokens;
self.output_tokens += rhs.output_tokens;
self.cache_read_input_tokens += rhs.cache_read_input_tokens;
self.cache_creation_input_tokens += rhs.cache_creation_input_tokens;
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionHeader {
pub format_version: u32,
pub session_id: String,
pub parent_session_id: Option<String>,
pub model: String,
pub created_at_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Entry {
pub id: String,
pub parent_id: Option<String>,
pub ts_ms: u64,
pub payload: EntryPayload,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum EntryPayload {
Header {
header: SessionHeader,
},
Item {
item: Item,
},
Usage {
usage: TokenUsage,
},
Cancelled {
reason: String,
},
Compaction {
digest: Vec<Item>,
prefix_end: usize,
kept_from: usize,
degraded: bool,
},
BranchMove {
keep_items: usize,
},
Supersede {
digest: Vec<Item>,
},
PendingAsk {
id: String,
summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
protected_why: Option<String>,
},
AskResolved {
id: String,
allowed: bool,
},
Rename {
name: String,
},
#[serde(other)]
Unknown,
}
pub fn new_ulid() -> String {
ulid::Ulid::new().to_string()
}
pub fn normalize_session_name(raw: &str) -> Option<String> {
let name = raw.trim();
(!name.is_empty() && name.chars().count() <= 64).then(|| name.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn roundtrip<T: Serialize + for<'a> Deserialize<'a>>(v: &T) -> String {
let a = serde_json::to_string(v).unwrap();
let back: T = serde_json::from_str(&a).unwrap();
let b = serde_json::to_string(&back).unwrap();
assert_eq!(
a, b,
"serialize → deserialize → serialize must be byte-identical"
);
a
}
#[test]
fn items_roundtrip_byte_identical() {
let items = vec![
Item::System {
text: "you are hotl".into(),
},
Item::User {
text: "hi".into(),
synthetic: None,
},
Item::User {
text: "<project-instructions>...</project-instructions>".into(),
synthetic: Some(SyntheticReason::ProjectInstructions),
},
Item::Assistant {
blocks: vec![
serde_json::json!({"type":"thinking","thinking":"","signature":"sig=="}),
serde_json::json!({"type":"text","text":"hello"}),
serde_json::json!({"type":"tool_use","id":"toolu_1","name":"read","input":{"path":"a.rs"}}),
],
},
Item::ToolResults {
results: vec![ToolResultItem {
tool_use_id: "toolu_1".into(),
content: "fn main() {}".into(),
is_error: false,
}],
},
];
for item in &items {
roundtrip(item);
}
}
#[test]
fn entry_roundtrip_and_mutation() {
let mut e = Entry {
id: new_ulid(),
parent_id: None,
ts_ms: 1,
payload: EntryPayload::Item {
item: Item::User {
text: "x".into(),
synthetic: None,
},
},
};
roundtrip(&e);
e.ts_ms = 2;
roundtrip(&e);
}
#[test]
fn unknown_variants_survive() {
let item: Item = serde_json::from_str(r#"{"type":"hologram","payload":{"x":1}}"#).unwrap();
assert_eq!(item, Item::Unknown);
let reason: SyntheticReason = serde_json::from_str(r#""quantum_nudge""#).unwrap();
assert_eq!(reason, SyntheticReason::Unknown);
let payload: EntryPayload =
serde_json::from_str(r#"{"kind":"visibility","target":"e1"}"#).unwrap();
assert_eq!(payload, EntryPayload::Unknown);
let stop: StopReason = serde_json::from_str(r#""cosmic_ray""#).unwrap();
assert_eq!(stop, StopReason::Other);
}
#[test]
fn assistant_views() {
let blocks = vec![
serde_json::json!({"type":"text","text":"I'll read "}),
serde_json::json!({"type":"text","text":"the file."}),
serde_json::json!({"type":"tool_use","id":"t1","name":"read","input":{"path":"x"}}),
];
assert_eq!(assistant_text(&blocks), "I'll read the file.");
let uses = assistant_tool_uses(&blocks);
assert_eq!(uses.len(), 1);
assert_eq!(uses[0].name, "read");
}
#[test]
fn rename_entry_roundtrips_with_snake_case_kind() {
let json = roundtrip(&EntryPayload::Rename {
name: "fix-auth".into(),
});
assert!(json.contains("\"kind\":\"rename\""), "wire kind: {json}");
assert!(json.contains("\"name\":\"fix-auth\""), "wire name: {json}");
}
#[test]
fn normalize_session_name_trims_and_bounds() {
assert_eq!(
normalize_session_name(" fix auth "),
Some("fix auth".into())
);
assert_eq!(normalize_session_name(" "), None);
assert_eq!(normalize_session_name(""), None);
let long = "x".repeat(65);
assert_eq!(normalize_session_name(&long), None);
let max = "é".repeat(64); assert_eq!(normalize_session_name(&max), Some(max.clone()));
}
}