1use atm_core::{HookEventType, SessionDomain, SessionId};
6use serde::Deserialize;
7
8#[derive(Debug, Clone, Deserialize)]
13pub struct RawStatusLine {
14 pub session_id: String,
15 #[serde(default)]
16 pub transcript_path: Option<String>,
17 #[serde(default)]
18 pub cwd: Option<String>,
19 #[serde(default)]
20 pub model: Option<RawModel>,
21 #[serde(default)]
22 pub workspace: Option<RawWorkspace>,
23 #[serde(default)]
24 pub version: Option<String>,
25 #[serde(default)]
26 pub cost: Option<RawCost>,
27 #[serde(default)]
28 pub context_window: Option<RawContextWindow>,
29 #[serde(default)]
30 pub exceeds_200k_tokens: Option<bool>,
31 #[serde(default)]
33 pub pid: Option<u32>,
34 #[serde(default)]
36 pub tmux_pane: Option<String>,
37}
38
39#[derive(Debug, Clone, Deserialize)]
40pub struct RawModel {
41 pub id: String,
42 #[serde(default)]
43 pub display_name: Option<String>,
44}
45
46#[derive(Debug, Clone, Deserialize)]
47pub struct RawWorkspace {
48 #[serde(default)]
49 pub current_dir: Option<String>,
50 #[serde(default)]
51 pub project_dir: Option<String>,
52}
53
54#[derive(Debug, Clone, Deserialize)]
55pub struct RawCost {
56 pub total_cost_usd: f64,
57 pub total_duration_ms: u64,
58 #[serde(default)]
59 pub total_api_duration_ms: u64,
60 #[serde(default)]
61 pub total_lines_added: u64,
62 #[serde(default)]
63 pub total_lines_removed: u64,
64}
65
66#[derive(Debug, Clone, Deserialize)]
67pub struct RawContextWindow {
68 #[serde(default)]
69 pub total_input_tokens: u64,
70 #[serde(default)]
71 pub total_output_tokens: u64,
72 #[serde(default = "default_context_window_size")]
73 pub context_window_size: u32,
74 #[serde(default)]
76 pub used_percentage: Option<f64>,
77 #[serde(default)]
79 pub remaining_percentage: Option<f64>,
80 #[serde(default)]
81 pub current_usage: Option<RawCurrentUsage>,
82}
83
84fn default_context_window_size() -> u32 {
85 200_000
86}
87
88#[derive(Debug, Clone, Deserialize)]
89pub struct RawCurrentUsage {
90 #[serde(default)]
91 pub input_tokens: u64,
92 #[serde(default)]
93 pub output_tokens: u64,
94 #[serde(default)]
95 pub cache_creation_input_tokens: u64,
96 #[serde(default)]
97 pub cache_read_input_tokens: u64,
98}
99
100impl RawStatusLine {
101 pub fn to_session_domain(&self) -> Option<SessionDomain> {
104 let model = self.model.as_ref()?;
106
107 let cost = self.cost.as_ref();
108 let context = self.context_window.as_ref();
109 let current = context.and_then(|c| c.current_usage.as_ref());
110
111 Some(SessionDomain::from_status_line(
112 &self.session_id,
113 &model.id,
114 cost.map(|c| c.total_cost_usd).unwrap_or(0.0),
115 cost.map(|c| c.total_duration_ms).unwrap_or(0),
116 cost.map(|c| c.total_api_duration_ms).unwrap_or(0),
117 cost.map(|c| c.total_lines_added).unwrap_or(0),
118 cost.map(|c| c.total_lines_removed).unwrap_or(0),
119 context.map(|c| c.total_input_tokens).unwrap_or(0),
120 context.map(|c| c.total_output_tokens).unwrap_or(0),
121 context.map(|c| c.context_window_size).unwrap_or(200_000),
122 current.map(|c| c.input_tokens).unwrap_or(0),
123 current.map(|c| c.output_tokens).unwrap_or(0),
124 current.map(|c| c.cache_creation_input_tokens).unwrap_or(0),
125 current.map(|c| c.cache_read_input_tokens).unwrap_or(0),
126 self.cwd.as_deref(),
127 self.version.as_deref(),
128 ))
129 }
130
131 pub fn update_session(&self, session: &mut SessionDomain) {
134 use atm_core::Model;
135
136 let cost = self.cost.as_ref();
137 let context = self.context_window.as_ref();
138 let current = context.and_then(|c| c.current_usage.as_ref());
139
140 if let Some(model) = &self.model {
142 session.model = Model::from_id(&model.id);
143 }
144
145 session.update_from_status_line(
146 cost.map(|c| c.total_cost_usd).unwrap_or(0.0),
147 cost.map(|c| c.total_duration_ms).unwrap_or(0),
148 cost.map(|c| c.total_api_duration_ms).unwrap_or(0),
149 cost.map(|c| c.total_lines_added).unwrap_or(0),
150 cost.map(|c| c.total_lines_removed).unwrap_or(0),
151 context.map(|c| c.total_input_tokens).unwrap_or(0),
152 context.map(|c| c.total_output_tokens).unwrap_or(0),
153 current.map(|c| c.input_tokens).unwrap_or(0),
154 current.map(|c| c.output_tokens).unwrap_or(0),
155 current.map(|c| c.cache_creation_input_tokens).unwrap_or(0),
156 current.map(|c| c.cache_read_input_tokens).unwrap_or(0),
157 );
158 }
159}
160
161#[derive(Debug, Clone, Deserialize)]
163pub struct RawHookEvent {
164 pub session_id: String,
165 pub hook_event_name: String,
166 #[serde(default)]
167 pub tool_name: Option<String>,
168 #[serde(default)]
169 pub tool_input: Option<serde_json::Value>,
170 #[serde(default)]
171 pub tool_use_id: Option<String>,
172 #[serde(default)]
174 pub pid: Option<u32>,
175 #[serde(default)]
177 pub tmux_pane: Option<String>,
178}
179
180impl RawHookEvent {
181 pub fn event_type(&self) -> Option<HookEventType> {
183 HookEventType::from_event_name(&self.hook_event_name)
184 }
185
186 pub fn session_id(&self) -> SessionId {
188 SessionId::new(&self.session_id)
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use atm_core::Model;
196
197 #[test]
198 fn test_raw_status_line_parsing() {
199 let json = r#"{
200 "session_id": "test-123",
201 "model": {"id": "claude-opus-4-5-20251101", "display_name": "Opus 4.5"},
202 "cost": {"total_cost_usd": 0.35, "total_duration_ms": 35000},
203 "context_window": {"total_input_tokens": 5000, "context_window_size": 200000}
204 }"#;
205
206 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
207 let session = raw.to_session_domain().expect("should create session");
208
209 assert_eq!(session.id.as_str(), "test-123");
210 assert_eq!(session.model, Model::Opus45);
211 assert!((session.cost.as_usd() - 0.35).abs() < 0.001);
212 assert_eq!(session.context.total_input_tokens.as_u64(), 5000);
213 }
214
215 #[test]
216 fn test_raw_hook_event_parsing() {
217 let json = r#"{
218 "session_id": "test-123",
219 "hook_event_name": "PreToolUse",
220 "tool_name": "Bash"
221 }"#;
222
223 let event: RawHookEvent = serde_json::from_str(json).unwrap();
224 assert_eq!(event.event_type(), Some(HookEventType::PreToolUse));
225 assert_eq!(event.tool_name.as_deref(), Some("Bash"));
226 }
227
228 #[test]
229 fn test_raw_status_line_with_current_usage() {
230 let json = r#"{
231 "session_id": "test-456",
232 "model": {"id": "claude-sonnet-4-20250514"},
233 "cost": {"total_cost_usd": 0.10, "total_duration_ms": 10000},
234 "context_window": {
235 "total_input_tokens": 1000,
236 "total_output_tokens": 500,
237 "context_window_size": 200000,
238 "current_usage": {
239 "input_tokens": 200,
240 "output_tokens": 100,
241 "cache_creation_input_tokens": 50,
242 "cache_read_input_tokens": 25
243 }
244 }
245 }"#;
246
247 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
248 let session = raw.to_session_domain().expect("should create session");
249
250 assert_eq!(session.context.current_input_tokens.as_u64(), 200);
251 assert_eq!(session.context.cache_creation_tokens.as_u64(), 50);
252 }
253
254 #[test]
255 fn test_raw_status_line_context_from_current_usage() {
256 let json = r#"{
259 "session_id": "test-pct",
260 "model": {"id": "claude-sonnet-4-20250514"},
261 "context_window": {
262 "total_input_tokens": 50000,
263 "total_output_tokens": 10000,
264 "context_window_size": 200000,
265 "current_usage": {
266 "input_tokens": 1000,
267 "output_tokens": 500,
268 "cache_creation_input_tokens": 2000,
269 "cache_read_input_tokens": 40000
270 }
271 }
272 }"#;
273
274 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
275 let session = raw.to_session_domain().expect("should create session");
276
277 assert_eq!(session.context.context_tokens().as_u64(), 43_000);
280 assert!((session.context.usage_percentage() - 21.5).abs() < 0.01);
281 }
282
283 #[test]
284 fn test_raw_status_line_zero_without_current_usage() {
285 let json = r#"{
287 "session_id": "test-fallback",
288 "model": {"id": "claude-sonnet-4-20250514"},
289 "context_window": {
290 "total_input_tokens": 50000,
291 "total_output_tokens": 10000,
292 "context_window_size": 200000
293 }
294 }"#;
295
296 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
297 let session = raw.to_session_domain().expect("should create session");
298
299 assert_eq!(session.context.context_tokens().as_u64(), 0);
301 assert!((session.context.usage_percentage() - 0.0).abs() < 0.01);
302 }
303
304 #[test]
305 fn test_raw_status_line_missing_model_returns_none() {
306 let json = r#"{"session_id": "test-789"}"#;
308
309 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
310 assert!(raw.to_session_domain().is_none());
311 }
312
313 #[test]
314 fn test_update_session_fills_in_model() {
315 use atm_core::{AgentType, SessionDomain};
316
317 let mut session = SessionDomain::new(
319 atm_core::SessionId::new("test-discovered"),
320 AgentType::GeneralPurpose,
321 Model::Unknown,
322 );
323 assert_eq!(session.model, Model::Unknown);
324
325 let json = r#"{
327 "session_id": "test-discovered",
328 "model": {"id": "claude-opus-4-5-20251101"},
329 "cost": {"total_cost_usd": 0.50, "total_duration_ms": 10000}
330 }"#;
331
332 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
333 raw.update_session(&mut session);
334
335 assert_eq!(session.model, Model::Opus45);
337 }
338
339 #[test]
340 fn test_raw_status_line_partial_data() {
341 let json = r#"{
343 "session_id": "test-partial",
344 "model": {"id": "claude-sonnet-4-20250514"}
345 }"#;
346
347 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
348 let session = raw.to_session_domain().expect("should create with defaults");
349
350 assert_eq!(session.id.as_str(), "test-partial");
351 assert!((session.cost.as_usd() - 0.0).abs() < 0.001);
352 assert_eq!(session.context.total_input_tokens.as_u64(), 0);
353 }
354}