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 #[serde(other)]
217 Unknown,
218}
219
220pub fn new_ulid() -> String {
221 ulid::Ulid::new().to_string()
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 fn roundtrip<T: Serialize + for<'a> Deserialize<'a>>(v: &T) -> String {
229 let a = serde_json::to_string(v).unwrap();
230 let back: T = serde_json::from_str(&a).unwrap();
231 let b = serde_json::to_string(&back).unwrap();
232 assert_eq!(
233 a, b,
234 "serialize → deserialize → serialize must be byte-identical"
235 );
236 a
237 }
238
239 #[test]
240 fn items_roundtrip_byte_identical() {
241 let items = vec![
242 Item::System {
243 text: "you are hotl".into(),
244 },
245 Item::User {
246 text: "hi".into(),
247 synthetic: None,
248 },
249 Item::User {
250 text: "<project-instructions>...</project-instructions>".into(),
251 synthetic: Some(SyntheticReason::ProjectInstructions),
252 },
253 Item::Assistant {
254 blocks: vec![
255 serde_json::json!({"type":"thinking","thinking":"","signature":"sig=="}),
256 serde_json::json!({"type":"text","text":"hello"}),
257 serde_json::json!({"type":"tool_use","id":"toolu_1","name":"read","input":{"path":"a.rs"}}),
258 ],
259 },
260 Item::ToolResults {
261 results: vec![ToolResultItem {
262 tool_use_id: "toolu_1".into(),
263 content: "fn main() {}".into(),
264 is_error: false,
265 }],
266 },
267 ];
268 for item in &items {
269 roundtrip(item);
270 }
271 }
272
273 #[test]
274 fn entry_roundtrip_and_mutation() {
275 let mut e = Entry {
276 id: new_ulid(),
277 parent_id: None,
278 ts_ms: 1,
279 payload: EntryPayload::Item {
280 item: Item::User {
281 text: "x".into(),
282 synthetic: None,
283 },
284 },
285 };
286 roundtrip(&e);
287 e.ts_ms = 2;
289 roundtrip(&e);
290 }
291
292 #[test]
293 fn unknown_variants_survive() {
294 let item: Item = serde_json::from_str(r#"{"type":"hologram","payload":{"x":1}}"#).unwrap();
295 assert_eq!(item, Item::Unknown);
296 let reason: SyntheticReason = serde_json::from_str(r#""quantum_nudge""#).unwrap();
297 assert_eq!(reason, SyntheticReason::Unknown);
298 let payload: EntryPayload =
299 serde_json::from_str(r#"{"kind":"visibility","target":"e1"}"#).unwrap();
300 assert_eq!(payload, EntryPayload::Unknown);
301 let stop: StopReason = serde_json::from_str(r#""cosmic_ray""#).unwrap();
302 assert_eq!(stop, StopReason::Other);
303 }
304
305 #[test]
306 fn assistant_views() {
307 let blocks = vec![
308 serde_json::json!({"type":"text","text":"I'll read "}),
309 serde_json::json!({"type":"text","text":"the file."}),
310 serde_json::json!({"type":"tool_use","id":"t1","name":"read","input":{"path":"x"}}),
311 ];
312 assert_eq!(assistant_text(&blocks), "I'll read the file.");
313 let uses = assistant_tool_uses(&blocks);
314 assert_eq!(uses.len(), 1);
315 assert_eq!(uses[0].name, "read");
316 }
317}