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,
Todos,
#[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, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TodoStatus {
Pending,
InProgress,
Completed,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Todo {
pub content: String,
pub status: TodoStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_form: Option<String>,
}
#[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,
}
fn is_zero_u64(n: &u64) -> bool {
*n == 0
}
#[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,
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub cache_creation_5m_input_tokens: u64,
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub cache_creation_1h_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;
self.cache_creation_5m_input_tokens += rhs.cache_creation_5m_input_tokens;
self.cache_creation_1h_input_tokens += rhs.cache_creation_1h_input_tokens;
}
}
impl TokenUsage {
pub fn hit_ratio(&self) -> Option<f64> {
if self.cache_read_input_tokens == 0 && self.cache_creation_input_tokens == 0 {
return None;
}
let total =
self.input_tokens + self.cache_read_input_tokens + self.cache_creation_input_tokens;
Some(self.cache_read_input_tokens as f64 / total as f64)
}
}
#[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,
},
ModeSet {
mode: String,
},
PendingQuestion {
id: String,
question: Question,
},
QuestionResolved {
id: String,
answer: String,
},
Todos {
items: Vec<Todo>,
},
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestionOption {
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Question {
pub header: String,
pub prompt: String,
pub options: Vec<QuestionOption>,
#[serde(default)]
pub multi: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub enum QuestionAnswer {
Selected(Vec<String>),
FreeText(String),
NoHuman,
}
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 question_types_and_entries_roundtrip() {
let q = Question {
header: "Auth".into(),
prompt: "Which provider?".into(),
options: vec![
QuestionOption {
label: "Keycloak".into(),
description: Some("self-hosted".into()),
},
QuestionOption {
label: "Auth0".into(),
description: None,
},
],
multi: false,
};
let pj = serde_json::to_string(&EntryPayload::PendingQuestion {
id: "q1".into(),
question: q.clone(),
})
.unwrap();
assert!(pj.contains("\"kind\":\"pending_question\""));
assert_eq!(
serde_json::from_str::<EntryPayload>(&pj).unwrap(),
EntryPayload::PendingQuestion {
id: "q1".into(),
question: q
}
);
let rj = serde_json::to_string(&EntryPayload::QuestionResolved {
id: "q1".into(),
answer: "Keycloak".into(),
})
.unwrap();
assert!(rj.contains("\"kind\":\"question_resolved\""));
}
#[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 mode_set_entry_roundtrips_snake_case() {
let j = serde_json::to_string(&EntryPayload::ModeSet {
mode: "plan".into(),
})
.unwrap();
assert!(j.contains("\"kind\":\"mode_set\""), "wire kind: {j}");
let back: EntryPayload = serde_json::from_str(&j).unwrap();
assert_eq!(
back,
EntryPayload::ModeSet {
mode: "plan".into()
}
);
}
#[test]
fn todo_types_roundtrip_and_absorb_unknown_status() {
let t = Todo {
content: "wire the gate".into(),
status: TodoStatus::InProgress,
active_form: Some("wiring the gate".into()),
};
let j = serde_json::to_string(&t).unwrap();
assert!(j.contains("\"status\":\"in_progress\""));
let back: Todo = serde_json::from_str(&j).unwrap();
assert_eq!(back, t);
let unk: TodoStatus = serde_json::from_str("\"blocked_on_ci\"").unwrap();
assert_eq!(unk, TodoStatus::Unknown);
let e = EntryPayload::Todos { items: vec![t] };
let ej = serde_json::to_string(&e).unwrap();
assert!(ej.contains("\"kind\":\"todos\""));
assert_eq!(serde_json::from_str::<EntryPayload>(&ej).unwrap(), e);
}
#[test]
fn hit_ratio_is_absent_without_cache_activity() {
let usage = TokenUsage {
input_tokens: 100,
output_tokens: 20,
..Default::default()
};
assert_eq!(usage.hit_ratio(), None);
}
#[test]
fn hit_ratio_divides_reads_by_total_prompt_tokens() {
let usage = TokenUsage {
input_tokens: 25,
output_tokens: 10,
cache_read_input_tokens: 50,
cache_creation_input_tokens: 25,
..Default::default()
};
assert_eq!(usage.hit_ratio(), Some(0.5));
}
#[test]
fn hit_ratio_is_present_and_zero_on_a_cache_write_with_no_reads() {
let usage = TokenUsage {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 100,
..Default::default()
};
assert_eq!(usage.hit_ratio(), Some(0.0));
}
#[test]
fn hit_ratio_never_divides_by_zero() {
assert_eq!(TokenUsage::default().hit_ratio(), None);
}
#[test]
fn token_usage_with_zero_ttl_buckets_serializes_byte_identical_to_before() {
let usage = TokenUsage {
input_tokens: 10,
output_tokens: 20,
cache_read_input_tokens: 5,
cache_creation_input_tokens: 7,
..Default::default()
};
let json = serde_json::to_string(&usage).unwrap();
assert_eq!(
json,
"{\"input_tokens\":10,\"output_tokens\":20,\"cache_read_input_tokens\":5,\
\"cache_creation_input_tokens\":7}"
);
assert!(!json.contains("cache_creation_5m_input_tokens"));
assert!(!json.contains("cache_creation_1h_input_tokens"));
let back: TokenUsage = serde_json::from_str(&json).unwrap();
assert_eq!(back, usage);
}
#[test]
fn token_usage_with_nonzero_ttl_buckets_round_trips() {
let usage = TokenUsage {
input_tokens: 10,
cache_creation_input_tokens: 300,
cache_creation_5m_input_tokens: 100,
cache_creation_1h_input_tokens: 200,
..Default::default()
};
let json = serde_json::to_string(&usage).unwrap();
assert!(json.contains("\"cache_creation_5m_input_tokens\":100"));
assert!(json.contains("\"cache_creation_1h_input_tokens\":200"));
let back: TokenUsage = serde_json::from_str(&json).unwrap();
assert_eq!(back, usage);
}
#[test]
fn token_usage_deserializes_old_bytes_with_no_ttl_buckets() {
let old = r#"{"input_tokens":1,"output_tokens":2,"cache_read_input_tokens":3,"cache_creation_input_tokens":4}"#;
let usage: TokenUsage = serde_json::from_str(old).unwrap();
assert_eq!(usage.cache_creation_5m_input_tokens, 0);
assert_eq!(usage.cache_creation_1h_input_tokens, 0);
assert_eq!(usage.cache_creation_input_tokens, 4);
}
#[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()));
}
}