1use serde::{Deserialize, Serialize};
5
6use crate::session_slot::SessionSlot;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum HandoffReason {
12 Fill,
14 Compaction,
16 #[default]
18 Manual,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct HandoffSection {
27 pub label: String,
28 pub lines: Vec<String>,
29}
30
31#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34pub struct HandoffBrief {
35 pub ground_truth: Vec<HandoffSection>,
36 pub judgment: Vec<HandoffSection>,
37}
38
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
41pub struct HandoffCycle {
42 pub requests: u32,
43 pub turns: u32,
44 pub prompt_tokens: u64,
45}
46
47#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
51pub struct HandoffSuccessor {
52 pub turns: u32,
54 pub reread_files: u32,
56 pub questions: u32,
58 pub corrections: u32,
60}
61
62#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
64pub struct HandoffGitState {
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub branch: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub head: Option<String>,
71 #[serde(default)]
73 pub dirty: Vec<String>,
74 #[serde(default)]
77 pub dirty_total: u32,
78 #[serde(default)]
80 pub diff: Vec<String>,
81}
82
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct HandoffRecord {
87 pub slot: SessionSlot,
88 pub from: String,
90 pub to: String,
92 pub reason: HandoffReason,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub fill_percent: Option<u8>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub total_tokens: Option<u64>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub max_tokens: Option<u64>,
100 pub at_percent: u8,
103 pub brief: HandoffBrief,
104 pub transcript_path: String,
106 pub at: String,
108 #[serde(default)]
109 pub cycle: HandoffCycle,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub successor: Option<HandoffSuccessor>,
113}
114
115impl HandoffRecord {
116 pub fn pair(&self) -> String {
118 format!("{} -> {}", short_id(&self.from), short_id(&self.to))
119 }
120}
121
122fn short_id(id: &str) -> &str {
125 id.get(..8).unwrap_or(id)
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 fn record() -> HandoffRecord {
133 HandoffRecord {
134 slot: SessionSlot::lead("Busytools", "forge"),
135 from: "9cfe315c-1111-2222-3333-444444444444".to_owned(),
136 to: "4b21e0a7-5555-6666-7777-888888888888".to_owned(),
137 reason: HandoffReason::Fill,
138 fill_percent: Some(85),
139 total_tokens: Some(867_000),
140 max_tokens: Some(1_020_000),
141 at_percent: 85,
142 brief: HandoffBrief::default(),
143 transcript_path: "/tmp/9cfe315c.jsonl".to_owned(),
144 at: "2026-10-24T08:00:00Z".to_owned(),
145 cycle: HandoffCycle { requests: 514, turns: 96, prompt_tokens: 4_970_000_000 },
146 successor: None,
147 }
148 }
149
150 #[test]
153 fn the_pair_names_both_sessions_short() {
154 assert_eq!(record().pair(), "9cfe315c -> 4b21e0a7");
155 }
156
157 #[test]
160 fn the_record_round_trips_with_an_unmeasured_successor() {
161 let json = serde_json::to_value(record()).expect("the record serializes");
162 let successor = json.get("successor");
163 assert!(successor.is_none(), "an unmeasured successor is not written");
164 let back: HandoffRecord = serde_json::from_value(json).expect("and reads back");
165 assert_eq!(back, record(), "the record survives the trip unchanged");
166 }
167
168 #[test]
171 fn a_record_without_the_optional_numbers_reads() {
172 let json = serde_json::json!({
173 "slot": {"org": "Busytools", "project": "forge", "label": "lead"},
174 "from": "a", "to": "b", "reason": "manual",
175 "at_percent": 85,
176 "brief": {"ground_truth": [], "judgment": []},
177 "transcript_path": "/tmp/a.jsonl", "at": "now"
178 });
179 let back: HandoffRecord = serde_json::from_value(json).expect("the record reads");
180 assert_eq!(back.reason, HandoffReason::Manual);
181 assert_eq!(back.fill_percent, None, "no reading is not a reading of zero");
182 assert_eq!(back.cycle.requests, 0, "and an absent cycle reads empty");
183 }
184}