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 Todos,
35 #[serde(other)]
36 Unknown,
37}
38
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42#[serde(tag = "type", rename_all = "snake_case")]
43pub enum Item {
44 System {
45 text: String,
46 },
47 User {
48 text: String,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 synthetic: Option<SyntheticReason>,
51 },
52 Assistant {
54 blocks: Vec<Value>,
55 },
56 ToolResults {
59 results: Vec<ToolResultItem>,
60 },
61 #[serde(other)]
62 Unknown,
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct ToolResultItem {
67 pub tool_use_id: String,
68 pub content: String,
69 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
70 pub is_error: bool,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum TodoStatus {
79 Pending,
80 InProgress,
81 Completed,
82 #[serde(other)]
83 Unknown,
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87pub struct Todo {
88 pub content: String,
89 pub status: TodoStatus,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub active_form: Option<String>,
93}
94
95#[derive(Debug, Clone, PartialEq)]
97pub struct ToolUse {
98 pub id: String,
99 pub name: String,
100 pub input: Value,
101}
102
103pub fn assistant_text(blocks: &[Value]) -> String {
105 blocks
106 .iter()
107 .filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
108 .filter_map(|b| b.get("text").and_then(Value::as_str))
109 .collect::<Vec<_>>()
110 .join("")
111}
112
113pub fn assistant_tool_uses(blocks: &[Value]) -> Vec<ToolUse> {
115 blocks
116 .iter()
117 .filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_use"))
118 .filter_map(|b| {
119 Some(ToolUse {
120 id: b.get("id")?.as_str()?.to_string(),
121 name: b.get("name")?.as_str()?.to_string(),
122 input: b.get("input").cloned().unwrap_or(Value::Null),
123 })
124 })
125 .collect()
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum StopReason {
132 EndTurn,
133 MaxTokens,
134 ToolUse,
135 StopSequence,
136 PauseTurn,
137 Refusal,
138 #[serde(other)]
139 Other,
140}
141
142fn is_zero_u64(n: &u64) -> bool {
145 *n == 0
146}
147
148#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
150pub struct TokenUsage {
151 #[serde(default)]
152 pub input_tokens: u64,
153 #[serde(default)]
154 pub output_tokens: u64,
155 #[serde(default)]
156 pub cache_read_input_tokens: u64,
157 #[serde(default)]
158 pub cache_creation_input_tokens: u64,
159 #[serde(default, skip_serializing_if = "is_zero_u64")]
167 pub cache_creation_5m_input_tokens: u64,
168 #[serde(default, skip_serializing_if = "is_zero_u64")]
171 pub cache_creation_1h_input_tokens: u64,
172}
173
174impl std::ops::AddAssign for TokenUsage {
175 fn add_assign(&mut self, rhs: Self) {
176 self.input_tokens += rhs.input_tokens;
177 self.output_tokens += rhs.output_tokens;
178 self.cache_read_input_tokens += rhs.cache_read_input_tokens;
179 self.cache_creation_input_tokens += rhs.cache_creation_input_tokens;
180 self.cache_creation_5m_input_tokens += rhs.cache_creation_5m_input_tokens;
181 self.cache_creation_1h_input_tokens += rhs.cache_creation_1h_input_tokens;
182 }
183}
184
185impl TokenUsage {
186 pub fn hit_ratio(&self) -> Option<f64> {
193 if self.cache_read_input_tokens == 0 && self.cache_creation_input_tokens == 0 {
194 return None;
195 }
196 let total =
197 self.input_tokens + self.cache_read_input_tokens + self.cache_creation_input_tokens;
198 Some(self.cache_read_input_tokens as f64 / total as f64)
199 }
200}
201
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203pub struct SessionHeader {
204 pub format_version: u32,
205 pub session_id: String,
206 pub parent_session_id: Option<String>,
208 pub model: String,
209 pub created_at_ms: u64,
210}
211
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
215pub struct Entry {
216 pub id: String,
217 pub parent_id: Option<String>,
218 pub ts_ms: u64,
219 pub payload: EntryPayload,
220}
221
222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
223#[serde(tag = "kind", rename_all = "snake_case")]
224pub enum EntryPayload {
225 Header {
226 header: SessionHeader,
227 },
228 Item {
229 item: Item,
230 },
231 Usage {
232 usage: TokenUsage,
233 },
234 Cancelled {
235 reason: String,
236 },
237 Compaction {
240 digest: Vec<Item>,
241 prefix_end: usize,
243 kept_from: usize,
247 degraded: bool,
249 },
250 BranchMove {
255 keep_items: usize,
256 },
257 Supersede {
260 digest: Vec<Item>,
261 },
262 PendingAsk {
267 id: String,
268 summary: String,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
270 protected_why: Option<String>,
271 },
272 AskResolved {
274 id: String,
275 allowed: bool,
276 },
277 Rename {
280 name: String,
281 },
282 ModeSet {
288 mode: String,
289 },
290 PendingQuestion {
296 id: String,
297 question: Question,
298 },
299 QuestionResolved {
303 id: String,
304 answer: String,
305 },
306 Todos {
312 items: Vec<Todo>,
313 },
314 #[serde(other)]
315 Unknown,
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322pub struct QuestionOption {
323 pub label: String,
324 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub description: Option<String>,
326}
327
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334pub struct Question {
335 pub header: String,
336 pub prompt: String,
337 pub options: Vec<QuestionOption>,
338 #[serde(default)]
339 pub multi: bool,
340}
341
342#[derive(Debug, Clone, PartialEq)]
351pub enum QuestionAnswer {
352 Selected(Vec<String>),
353 FreeText(String),
354 NoHuman,
355}
356
357pub fn new_ulid() -> String {
358 ulid::Ulid::new().to_string()
359}
360
361pub fn normalize_session_name(raw: &str) -> Option<String> {
364 let name = raw.trim();
365 (!name.is_empty() && name.chars().count() <= 64).then(|| name.to_string())
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 fn roundtrip<T: Serialize + for<'a> Deserialize<'a>>(v: &T) -> String {
373 let a = serde_json::to_string(v).unwrap();
374 let back: T = serde_json::from_str(&a).unwrap();
375 let b = serde_json::to_string(&back).unwrap();
376 assert_eq!(
377 a, b,
378 "serialize → deserialize → serialize must be byte-identical"
379 );
380 a
381 }
382
383 #[test]
384 fn items_roundtrip_byte_identical() {
385 let items = vec![
386 Item::System {
387 text: "you are hotl".into(),
388 },
389 Item::User {
390 text: "hi".into(),
391 synthetic: None,
392 },
393 Item::User {
394 text: "<project-instructions>...</project-instructions>".into(),
395 synthetic: Some(SyntheticReason::ProjectInstructions),
396 },
397 Item::Assistant {
398 blocks: vec![
399 serde_json::json!({"type":"thinking","thinking":"","signature":"sig=="}),
400 serde_json::json!({"type":"text","text":"hello"}),
401 serde_json::json!({"type":"tool_use","id":"toolu_1","name":"read","input":{"path":"a.rs"}}),
402 ],
403 },
404 Item::ToolResults {
405 results: vec![ToolResultItem {
406 tool_use_id: "toolu_1".into(),
407 content: "fn main() {}".into(),
408 is_error: false,
409 }],
410 },
411 ];
412 for item in &items {
413 roundtrip(item);
414 }
415 }
416
417 #[test]
418 fn question_types_and_entries_roundtrip() {
419 let q = Question {
420 header: "Auth".into(),
421 prompt: "Which provider?".into(),
422 options: vec![
423 QuestionOption {
424 label: "Keycloak".into(),
425 description: Some("self-hosted".into()),
426 },
427 QuestionOption {
428 label: "Auth0".into(),
429 description: None,
430 },
431 ],
432 multi: false,
433 };
434 let pj = serde_json::to_string(&EntryPayload::PendingQuestion {
435 id: "q1".into(),
436 question: q.clone(),
437 })
438 .unwrap();
439 assert!(pj.contains("\"kind\":\"pending_question\""));
440 assert_eq!(
441 serde_json::from_str::<EntryPayload>(&pj).unwrap(),
442 EntryPayload::PendingQuestion {
443 id: "q1".into(),
444 question: q
445 }
446 );
447 let rj = serde_json::to_string(&EntryPayload::QuestionResolved {
448 id: "q1".into(),
449 answer: "Keycloak".into(),
450 })
451 .unwrap();
452 assert!(rj.contains("\"kind\":\"question_resolved\""));
453 }
454
455 #[test]
456 fn entry_roundtrip_and_mutation() {
457 let mut e = Entry {
458 id: new_ulid(),
459 parent_id: None,
460 ts_ms: 1,
461 payload: EntryPayload::Item {
462 item: Item::User {
463 text: "x".into(),
464 synthetic: None,
465 },
466 },
467 };
468 roundtrip(&e);
469 e.ts_ms = 2;
471 roundtrip(&e);
472 }
473
474 #[test]
475 fn unknown_variants_survive() {
476 let item: Item = serde_json::from_str(r#"{"type":"hologram","payload":{"x":1}}"#).unwrap();
477 assert_eq!(item, Item::Unknown);
478 let reason: SyntheticReason = serde_json::from_str(r#""quantum_nudge""#).unwrap();
479 assert_eq!(reason, SyntheticReason::Unknown);
480 let payload: EntryPayload =
481 serde_json::from_str(r#"{"kind":"visibility","target":"e1"}"#).unwrap();
482 assert_eq!(payload, EntryPayload::Unknown);
483 let stop: StopReason = serde_json::from_str(r#""cosmic_ray""#).unwrap();
484 assert_eq!(stop, StopReason::Other);
485 }
486
487 #[test]
488 fn assistant_views() {
489 let blocks = vec![
490 serde_json::json!({"type":"text","text":"I'll read "}),
491 serde_json::json!({"type":"text","text":"the file."}),
492 serde_json::json!({"type":"tool_use","id":"t1","name":"read","input":{"path":"x"}}),
493 ];
494 assert_eq!(assistant_text(&blocks), "I'll read the file.");
495 let uses = assistant_tool_uses(&blocks);
496 assert_eq!(uses.len(), 1);
497 assert_eq!(uses[0].name, "read");
498 }
499
500 #[test]
501 fn rename_entry_roundtrips_with_snake_case_kind() {
502 let json = roundtrip(&EntryPayload::Rename {
503 name: "fix-auth".into(),
504 });
505 assert!(json.contains("\"kind\":\"rename\""), "wire kind: {json}");
506 assert!(json.contains("\"name\":\"fix-auth\""), "wire name: {json}");
507 }
508
509 #[test]
510 fn mode_set_entry_roundtrips_snake_case() {
511 let j = serde_json::to_string(&EntryPayload::ModeSet {
512 mode: "plan".into(),
513 })
514 .unwrap();
515 assert!(j.contains("\"kind\":\"mode_set\""), "wire kind: {j}");
516 let back: EntryPayload = serde_json::from_str(&j).unwrap();
517 assert_eq!(
518 back,
519 EntryPayload::ModeSet {
520 mode: "plan".into()
521 }
522 );
523 }
524
525 #[test]
526 fn todo_types_roundtrip_and_absorb_unknown_status() {
527 let t = Todo {
528 content: "wire the gate".into(),
529 status: TodoStatus::InProgress,
530 active_form: Some("wiring the gate".into()),
531 };
532 let j = serde_json::to_string(&t).unwrap();
533 assert!(j.contains("\"status\":\"in_progress\""));
534 let back: Todo = serde_json::from_str(&j).unwrap();
535 assert_eq!(back, t);
536 let unk: TodoStatus = serde_json::from_str("\"blocked_on_ci\"").unwrap();
537 assert_eq!(unk, TodoStatus::Unknown);
538 let e = EntryPayload::Todos { items: vec![t] };
539 let ej = serde_json::to_string(&e).unwrap();
540 assert!(ej.contains("\"kind\":\"todos\""));
541 assert_eq!(serde_json::from_str::<EntryPayload>(&ej).unwrap(), e);
542 }
543
544 #[test]
545 fn hit_ratio_is_absent_without_cache_activity() {
546 let usage = TokenUsage {
549 input_tokens: 100,
550 output_tokens: 20,
551 ..Default::default()
552 };
553 assert_eq!(usage.hit_ratio(), None);
554 }
555
556 #[test]
557 fn hit_ratio_divides_reads_by_total_prompt_tokens() {
558 let usage = TokenUsage {
559 input_tokens: 25,
560 output_tokens: 10,
561 cache_read_input_tokens: 50,
562 cache_creation_input_tokens: 25,
563 ..Default::default()
564 };
565 assert_eq!(usage.hit_ratio(), Some(0.5));
566 }
567
568 #[test]
569 fn hit_ratio_is_present_and_zero_on_a_cache_write_with_no_reads() {
570 let usage = TokenUsage {
573 input_tokens: 0,
574 output_tokens: 0,
575 cache_read_input_tokens: 0,
576 cache_creation_input_tokens: 100,
577 ..Default::default()
578 };
579 assert_eq!(usage.hit_ratio(), Some(0.0));
580 }
581
582 #[test]
583 fn hit_ratio_never_divides_by_zero() {
584 assert_eq!(TokenUsage::default().hit_ratio(), None);
585 }
586
587 #[test]
588 fn token_usage_with_zero_ttl_buckets_serializes_byte_identical_to_before() {
589 let usage = TokenUsage {
594 input_tokens: 10,
595 output_tokens: 20,
596 cache_read_input_tokens: 5,
597 cache_creation_input_tokens: 7,
598 ..Default::default()
599 };
600 let json = serde_json::to_string(&usage).unwrap();
601 assert_eq!(
602 json,
603 "{\"input_tokens\":10,\"output_tokens\":20,\"cache_read_input_tokens\":5,\
604 \"cache_creation_input_tokens\":7}"
605 );
606 assert!(!json.contains("cache_creation_5m_input_tokens"));
607 assert!(!json.contains("cache_creation_1h_input_tokens"));
608 let back: TokenUsage = serde_json::from_str(&json).unwrap();
609 assert_eq!(back, usage);
610 }
611
612 #[test]
613 fn token_usage_with_nonzero_ttl_buckets_round_trips() {
614 let usage = TokenUsage {
615 input_tokens: 10,
616 cache_creation_input_tokens: 300,
617 cache_creation_5m_input_tokens: 100,
618 cache_creation_1h_input_tokens: 200,
619 ..Default::default()
620 };
621 let json = serde_json::to_string(&usage).unwrap();
622 assert!(json.contains("\"cache_creation_5m_input_tokens\":100"));
623 assert!(json.contains("\"cache_creation_1h_input_tokens\":200"));
624 let back: TokenUsage = serde_json::from_str(&json).unwrap();
625 assert_eq!(back, usage);
626 }
627
628 #[test]
629 fn token_usage_deserializes_old_bytes_with_no_ttl_buckets() {
630 let old = r#"{"input_tokens":1,"output_tokens":2,"cache_read_input_tokens":3,"cache_creation_input_tokens":4}"#;
633 let usage: TokenUsage = serde_json::from_str(old).unwrap();
634 assert_eq!(usage.cache_creation_5m_input_tokens, 0);
635 assert_eq!(usage.cache_creation_1h_input_tokens, 0);
636 assert_eq!(usage.cache_creation_input_tokens, 4);
637 }
638
639 #[test]
640 fn normalize_session_name_trims_and_bounds() {
641 assert_eq!(
642 normalize_session_name(" fix auth "),
643 Some("fix auth".into())
644 );
645 assert_eq!(normalize_session_name(" "), None);
646 assert_eq!(normalize_session_name(""), None);
647 let long = "x".repeat(65);
648 assert_eq!(normalize_session_name(&long), None);
649 let max = "é".repeat(64); assert_eq!(normalize_session_name(&max), Some(max.clone()));
651 }
652}