1use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16pub const FORMAT_VERSION: u32 = 1;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum SyntheticReason {
24 ProjectInstructions,
25 SystemReminder,
26 Steer,
27 CompactionSummary,
28 SubagentResult,
29 DoomLoopNudge,
30 RetryFeedback,
31 Moim,
32 Memory,
33 SubdirInstructions,
34 #[serde(other)]
35 Unknown,
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41#[serde(tag = "type", rename_all = "snake_case")]
42pub enum Item {
43 System {
44 text: String,
45 },
46 User {
47 text: String,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 synthetic: Option<SyntheticReason>,
50 },
51 Assistant {
53 blocks: Vec<Value>,
54 },
55 ToolResults {
58 results: Vec<ToolResultItem>,
59 },
60 #[serde(other)]
61 Unknown,
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65pub struct ToolResultItem {
66 pub tool_use_id: String,
67 pub content: String,
68 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
69 pub is_error: bool,
70}
71
72#[derive(Debug, Clone, PartialEq)]
74pub struct ToolUse {
75 pub id: String,
76 pub name: String,
77 pub input: Value,
78}
79
80pub fn assistant_text(blocks: &[Value]) -> String {
82 blocks
83 .iter()
84 .filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
85 .filter_map(|b| b.get("text").and_then(Value::as_str))
86 .collect::<Vec<_>>()
87 .join("")
88}
89
90pub fn assistant_tool_uses(blocks: &[Value]) -> Vec<ToolUse> {
92 blocks
93 .iter()
94 .filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_use"))
95 .filter_map(|b| {
96 Some(ToolUse {
97 id: b.get("id")?.as_str()?.to_string(),
98 name: b.get("name")?.as_str()?.to_string(),
99 input: b.get("input").cloned().unwrap_or(Value::Null),
100 })
101 })
102 .collect()
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum StopReason {
109 EndTurn,
110 MaxTokens,
111 ToolUse,
112 StopSequence,
113 PauseTurn,
114 Refusal,
115 #[serde(other)]
116 Other,
117}
118
119#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
121pub struct TokenUsage {
122 #[serde(default)]
123 pub input_tokens: u64,
124 #[serde(default)]
125 pub output_tokens: u64,
126 #[serde(default)]
127 pub cache_read_input_tokens: u64,
128 #[serde(default)]
129 pub cache_creation_input_tokens: u64,
130}
131
132impl std::ops::AddAssign for TokenUsage {
133 fn add_assign(&mut self, rhs: Self) {
134 self.input_tokens += rhs.input_tokens;
135 self.output_tokens += rhs.output_tokens;
136 self.cache_read_input_tokens += rhs.cache_read_input_tokens;
137 self.cache_creation_input_tokens += rhs.cache_creation_input_tokens;
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
142pub struct SessionHeader {
143 pub format_version: u32,
144 pub session_id: String,
145 pub parent_session_id: Option<String>,
147 pub model: String,
148 pub created_at_ms: u64,
149}
150
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154pub struct Entry {
155 pub id: String,
156 pub parent_id: Option<String>,
157 pub ts_ms: u64,
158 pub payload: EntryPayload,
159}
160
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162#[serde(tag = "kind", rename_all = "snake_case")]
163pub enum EntryPayload {
164 Header {
165 header: SessionHeader,
166 },
167 Item {
168 item: Item,
169 },
170 Usage {
171 usage: TokenUsage,
172 },
173 Cancelled {
174 reason: String,
175 },
176 Compaction {
179 digest: Vec<Item>,
180 prefix_end: usize,
182 kept_from: usize,
186 degraded: bool,
188 },
189 BranchMove {
194 keep_items: usize,
195 },
196 Supersede {
199 digest: Vec<Item>,
200 },
201 PendingAsk {
206 id: String,
207 summary: String,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 protected_why: Option<String>,
210 },
211 AskResolved {
213 id: String,
214 allowed: bool,
215 },
216 Rename {
219 name: String,
220 },
221 #[serde(other)]
222 Unknown,
223}
224
225pub fn new_ulid() -> String {
226 ulid::Ulid::new().to_string()
227}
228
229pub fn normalize_session_name(raw: &str) -> Option<String> {
232 let name = raw.trim();
233 (!name.is_empty() && name.chars().count() <= 64).then(|| name.to_string())
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 fn roundtrip<T: Serialize + for<'a> Deserialize<'a>>(v: &T) -> String {
241 let a = serde_json::to_string(v).unwrap();
242 let back: T = serde_json::from_str(&a).unwrap();
243 let b = serde_json::to_string(&back).unwrap();
244 assert_eq!(
245 a, b,
246 "serialize → deserialize → serialize must be byte-identical"
247 );
248 a
249 }
250
251 #[test]
252 fn items_roundtrip_byte_identical() {
253 let items = vec![
254 Item::System {
255 text: "you are hotl".into(),
256 },
257 Item::User {
258 text: "hi".into(),
259 synthetic: None,
260 },
261 Item::User {
262 text: "<project-instructions>...</project-instructions>".into(),
263 synthetic: Some(SyntheticReason::ProjectInstructions),
264 },
265 Item::Assistant {
266 blocks: vec![
267 serde_json::json!({"type":"thinking","thinking":"","signature":"sig=="}),
268 serde_json::json!({"type":"text","text":"hello"}),
269 serde_json::json!({"type":"tool_use","id":"toolu_1","name":"read","input":{"path":"a.rs"}}),
270 ],
271 },
272 Item::ToolResults {
273 results: vec![ToolResultItem {
274 tool_use_id: "toolu_1".into(),
275 content: "fn main() {}".into(),
276 is_error: false,
277 }],
278 },
279 ];
280 for item in &items {
281 roundtrip(item);
282 }
283 }
284
285 #[test]
286 fn entry_roundtrip_and_mutation() {
287 let mut e = Entry {
288 id: new_ulid(),
289 parent_id: None,
290 ts_ms: 1,
291 payload: EntryPayload::Item {
292 item: Item::User {
293 text: "x".into(),
294 synthetic: None,
295 },
296 },
297 };
298 roundtrip(&e);
299 e.ts_ms = 2;
301 roundtrip(&e);
302 }
303
304 #[test]
305 fn unknown_variants_survive() {
306 let item: Item = serde_json::from_str(r#"{"type":"hologram","payload":{"x":1}}"#).unwrap();
307 assert_eq!(item, Item::Unknown);
308 let reason: SyntheticReason = serde_json::from_str(r#""quantum_nudge""#).unwrap();
309 assert_eq!(reason, SyntheticReason::Unknown);
310 let payload: EntryPayload =
311 serde_json::from_str(r#"{"kind":"visibility","target":"e1"}"#).unwrap();
312 assert_eq!(payload, EntryPayload::Unknown);
313 let stop: StopReason = serde_json::from_str(r#""cosmic_ray""#).unwrap();
314 assert_eq!(stop, StopReason::Other);
315 }
316
317 #[test]
318 fn assistant_views() {
319 let blocks = vec![
320 serde_json::json!({"type":"text","text":"I'll read "}),
321 serde_json::json!({"type":"text","text":"the file."}),
322 serde_json::json!({"type":"tool_use","id":"t1","name":"read","input":{"path":"x"}}),
323 ];
324 assert_eq!(assistant_text(&blocks), "I'll read the file.");
325 let uses = assistant_tool_uses(&blocks);
326 assert_eq!(uses.len(), 1);
327 assert_eq!(uses[0].name, "read");
328 }
329
330 #[test]
331 fn rename_entry_roundtrips_with_snake_case_kind() {
332 let json = roundtrip(&EntryPayload::Rename {
333 name: "fix-auth".into(),
334 });
335 assert!(json.contains("\"kind\":\"rename\""), "wire kind: {json}");
336 assert!(json.contains("\"name\":\"fix-auth\""), "wire name: {json}");
337 }
338
339 #[test]
340 fn normalize_session_name_trims_and_bounds() {
341 assert_eq!(
342 normalize_session_name(" fix auth "),
343 Some("fix auth".into())
344 );
345 assert_eq!(normalize_session_name(" "), None);
346 assert_eq!(normalize_session_name(""), None);
347 let long = "x".repeat(65);
348 assert_eq!(normalize_session_name(&long), None);
349 let max = "é".repeat(64); assert_eq!(normalize_session_name(&max), Some(max.clone()));
351 }
352}