1use atm_core::{HookEventType, SessionDomain, SessionId, StatusLineData};
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_status_line_data(&self) -> Option<StatusLineData> {
105 let model = self.model.as_ref()?;
106 let cost = self.cost.as_ref();
107 let context = self.context_window.as_ref();
108 let current = context.and_then(|c| c.current_usage.as_ref());
109
110 Some(StatusLineData {
111 session_id: self.session_id.clone(),
112 model_id: model.id.clone(),
113 cost_usd: cost.map(|c| c.total_cost_usd).unwrap_or(0.0),
114 total_duration_ms: cost.map(|c| c.total_duration_ms).unwrap_or(0),
115 api_duration_ms: cost.map(|c| c.total_api_duration_ms).unwrap_or(0),
116 lines_added: cost.map(|c| c.total_lines_added).unwrap_or(0),
117 lines_removed: cost.map(|c| c.total_lines_removed).unwrap_or(0),
118 total_input_tokens: context.map(|c| c.total_input_tokens).unwrap_or(0),
119 total_output_tokens: context.map(|c| c.total_output_tokens).unwrap_or(0),
120 context_window_size: context.map(|c| c.context_window_size).unwrap_or(200_000),
121 current_input_tokens: current.map(|c| c.input_tokens).unwrap_or(0),
122 current_output_tokens: current.map(|c| c.output_tokens).unwrap_or(0),
123 cache_creation_tokens: current.map(|c| c.cache_creation_input_tokens).unwrap_or(0),
124 cache_read_tokens: current.map(|c| c.cache_read_input_tokens).unwrap_or(0),
125 cwd: self.cwd.clone(),
126 version: self.version.clone(),
127 })
128 }
129
130 pub fn to_session_domain(&self) -> Option<SessionDomain> {
133 let data = self.to_status_line_data()?;
134 Some(SessionDomain::from_status_line(&data))
135 }
136
137 pub fn update_session(&self, session: &mut SessionDomain) {
140 use atm_core::Model;
141
142 if let Some(model) = &self.model {
144 session.model = Model::from_id(&model.id);
145 }
146
147 let cost = self.cost.as_ref();
149 let context = self.context_window.as_ref();
150 let current = context.and_then(|c| c.current_usage.as_ref());
151
152 let data = StatusLineData {
153 session_id: self.session_id.clone(),
154 model_id: String::new(), cost_usd: cost.map(|c| c.total_cost_usd).unwrap_or(0.0),
156 total_duration_ms: cost.map(|c| c.total_duration_ms).unwrap_or(0),
157 api_duration_ms: cost.map(|c| c.total_api_duration_ms).unwrap_or(0),
158 lines_added: cost.map(|c| c.total_lines_added).unwrap_or(0),
159 lines_removed: cost.map(|c| c.total_lines_removed).unwrap_or(0),
160 total_input_tokens: context.map(|c| c.total_input_tokens).unwrap_or(0),
161 total_output_tokens: context.map(|c| c.total_output_tokens).unwrap_or(0),
162 context_window_size: context.map(|c| c.context_window_size).unwrap_or(200_000),
163 current_input_tokens: current.map(|c| c.input_tokens).unwrap_or(0),
164 current_output_tokens: current.map(|c| c.output_tokens).unwrap_or(0),
165 cache_creation_tokens: current.map(|c| c.cache_creation_input_tokens).unwrap_or(0),
166 cache_read_tokens: current.map(|c| c.cache_read_input_tokens).unwrap_or(0),
167 cwd: self.cwd.clone(),
168 version: self.version.clone(),
169 };
170
171 session.update_from_status_line(&data);
172 }
173}
174
175#[derive(Debug, Clone, Deserialize)]
180pub struct RawHookEvent {
181 pub session_id: String,
183 pub hook_event_name: String,
184 #[serde(default)]
185 pub cwd: Option<String>,
186 #[serde(default)]
187 pub permission_mode: Option<String>,
188
189 #[serde(default)]
191 pub pid: Option<u32>,
192 #[serde(default)]
193 pub tmux_pane: Option<String>,
194
195 #[serde(default)]
197 pub tool_name: Option<String>,
198 #[serde(default)]
199 pub tool_input: Option<serde_json::Value>,
200 #[serde(default)]
201 pub tool_response: Option<serde_json::Value>,
202 #[serde(default)]
203 pub tool_use_id: Option<String>,
204
205 #[serde(default)]
207 pub prompt: Option<String>,
208
209 #[serde(default)]
211 pub stop_hook_active: Option<bool>,
212
213 #[serde(default)]
215 pub agent_id: Option<String>,
216 #[serde(default)]
217 pub agent_type: Option<String>,
218 #[serde(default)]
219 pub agent_transcript_path: Option<String>,
220
221 #[serde(default)]
223 pub source: Option<String>,
224 #[serde(default)]
225 pub reason: Option<String>,
226 #[serde(default)]
227 pub model: Option<String>,
228
229 #[serde(default)]
231 pub trigger: Option<String>,
232 #[serde(default)]
233 pub custom_instructions: Option<String>,
234
235 #[serde(default)]
237 pub notification_type: Option<String>,
238 #[serde(default)]
239 pub message: Option<String>,
240}
241
242impl RawHookEvent {
243 pub fn event_type(&self) -> Option<HookEventType> {
245 HookEventType::from_event_name(&self.hook_event_name)
246 }
247
248 pub fn session_id(&self) -> SessionId {
250 SessionId::new(&self.session_id)
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use atm_core::Model;
258
259 #[test]
260 fn test_raw_status_line_parsing() {
261 let json = r#"{
262 "session_id": "test-123",
263 "model": {"id": "claude-opus-4-5-20251101", "display_name": "Opus 4.5"},
264 "cost": {"total_cost_usd": 0.35, "total_duration_ms": 35000},
265 "context_window": {"total_input_tokens": 5000, "context_window_size": 200000}
266 }"#;
267
268 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
269 let session = raw.to_session_domain().expect("should create session");
270
271 assert_eq!(session.id.as_str(), "test-123");
272 assert_eq!(session.model, Model::Opus45);
273 assert!((session.cost.as_usd() - 0.35).abs() < 0.001);
274 assert_eq!(session.context.total_input_tokens.as_u64(), 5000);
275 }
276
277 #[test]
278 fn test_raw_hook_event_parsing() {
279 let json = r#"{
280 "session_id": "test-123",
281 "hook_event_name": "PreToolUse",
282 "tool_name": "Bash"
283 }"#;
284
285 let event: RawHookEvent = serde_json::from_str(json).unwrap();
286 assert_eq!(event.event_type(), Some(HookEventType::PreToolUse));
287 assert_eq!(event.tool_name.as_deref(), Some("Bash"));
288 }
289
290 #[test]
291 fn test_raw_status_line_with_current_usage() {
292 let json = r#"{
293 "session_id": "test-456",
294 "model": {"id": "claude-sonnet-4-20250514"},
295 "cost": {"total_cost_usd": 0.10, "total_duration_ms": 10000},
296 "context_window": {
297 "total_input_tokens": 1000,
298 "total_output_tokens": 500,
299 "context_window_size": 200000,
300 "current_usage": {
301 "input_tokens": 200,
302 "output_tokens": 100,
303 "cache_creation_input_tokens": 50,
304 "cache_read_input_tokens": 25
305 }
306 }
307 }"#;
308
309 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
310 let session = raw.to_session_domain().expect("should create session");
311
312 assert_eq!(session.context.current_input_tokens.as_u64(), 200);
313 assert_eq!(session.context.cache_creation_tokens.as_u64(), 50);
314 }
315
316 #[test]
317 fn test_raw_status_line_context_from_current_usage() {
318 let json = r#"{
321 "session_id": "test-pct",
322 "model": {"id": "claude-sonnet-4-20250514"},
323 "context_window": {
324 "total_input_tokens": 50000,
325 "total_output_tokens": 10000,
326 "context_window_size": 200000,
327 "current_usage": {
328 "input_tokens": 1000,
329 "output_tokens": 500,
330 "cache_creation_input_tokens": 2000,
331 "cache_read_input_tokens": 40000
332 }
333 }
334 }"#;
335
336 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
337 let session = raw.to_session_domain().expect("should create session");
338
339 assert_eq!(session.context.context_tokens().as_u64(), 43_000);
342 assert!((session.context.usage_percentage() - 21.5).abs() < 0.01);
343 }
344
345 #[test]
346 fn test_raw_status_line_zero_without_current_usage() {
347 let json = r#"{
349 "session_id": "test-fallback",
350 "model": {"id": "claude-sonnet-4-20250514"},
351 "context_window": {
352 "total_input_tokens": 50000,
353 "total_output_tokens": 10000,
354 "context_window_size": 200000
355 }
356 }"#;
357
358 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
359 let session = raw.to_session_domain().expect("should create session");
360
361 assert_eq!(session.context.context_tokens().as_u64(), 0);
363 assert!((session.context.usage_percentage() - 0.0).abs() < 0.01);
364 }
365
366 #[test]
367 fn test_raw_status_line_missing_model_returns_none() {
368 let json = r#"{"session_id": "test-789"}"#;
370
371 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
372 assert!(raw.to_session_domain().is_none());
373 }
374
375 #[test]
376 fn test_raw_hook_event_stop() {
377 let json = r#"{
378 "session_id": "test-123",
379 "hook_event_name": "Stop",
380 "stop_hook_active": true
381 }"#;
382
383 let event: RawHookEvent = serde_json::from_str(json).unwrap();
384 assert_eq!(event.event_type(), Some(HookEventType::Stop));
385 assert_eq!(event.stop_hook_active, Some(true));
386 }
387
388 #[test]
389 fn test_raw_hook_event_user_prompt() {
390 let json = r#"{
391 "session_id": "test-123",
392 "hook_event_name": "UserPromptSubmit",
393 "prompt": "Help me write a function"
394 }"#;
395
396 let event: RawHookEvent = serde_json::from_str(json).unwrap();
397 assert_eq!(event.event_type(), Some(HookEventType::UserPromptSubmit));
398 assert_eq!(event.prompt.as_deref(), Some("Help me write a function"));
399 }
400
401 #[test]
402 fn test_raw_hook_event_subagent_start() {
403 let json = r#"{
404 "session_id": "test-123",
405 "hook_event_name": "SubagentStart",
406 "agent_id": "agent_456",
407 "agent_type": "Explore"
408 }"#;
409
410 let event: RawHookEvent = serde_json::from_str(json).unwrap();
411 assert_eq!(event.event_type(), Some(HookEventType::SubagentStart));
412 assert_eq!(event.agent_id.as_deref(), Some("agent_456"));
413 assert_eq!(event.agent_type.as_deref(), Some("Explore"));
414 }
415
416 #[test]
417 fn test_raw_hook_event_notification() {
418 let json = r#"{
419 "session_id": "test-123",
420 "hook_event_name": "Notification",
421 "notification_type": "permission_prompt",
422 "message": "Allow tool execution?"
423 }"#;
424
425 let event: RawHookEvent = serde_json::from_str(json).unwrap();
426 assert_eq!(event.event_type(), Some(HookEventType::Notification));
427 assert_eq!(event.notification_type.as_deref(), Some("permission_prompt"));
428 }
429
430 #[test]
431 fn test_raw_hook_event_session_start() {
432 let json = r#"{
433 "session_id": "test-123",
434 "hook_event_name": "SessionStart",
435 "source": "resume",
436 "model": "claude-opus-4-5-20251101"
437 }"#;
438
439 let event: RawHookEvent = serde_json::from_str(json).unwrap();
440 assert_eq!(event.event_type(), Some(HookEventType::SessionStart));
441 assert_eq!(event.source.as_deref(), Some("resume"));
442 }
443
444 #[test]
445 fn test_raw_hook_event_pre_compact() {
446 let json = r#"{
447 "session_id": "test-123",
448 "hook_event_name": "PreCompact",
449 "trigger": "auto"
450 }"#;
451
452 let event: RawHookEvent = serde_json::from_str(json).unwrap();
453 assert_eq!(event.event_type(), Some(HookEventType::PreCompact));
454 assert_eq!(event.trigger.as_deref(), Some("auto"));
455 }
456
457 #[test]
458 fn test_update_session_fills_in_model() {
459 use atm_core::{AgentType, SessionDomain};
460
461 let mut session = SessionDomain::new(
463 atm_core::SessionId::new("test-discovered"),
464 AgentType::GeneralPurpose,
465 Model::Unknown,
466 );
467 assert_eq!(session.model, Model::Unknown);
468
469 let json = r#"{
471 "session_id": "test-discovered",
472 "model": {"id": "claude-opus-4-5-20251101"},
473 "cost": {"total_cost_usd": 0.50, "total_duration_ms": 10000}
474 }"#;
475
476 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
477 raw.update_session(&mut session);
478
479 assert_eq!(session.model, Model::Opus45);
481 }
482
483 #[test]
484 fn test_raw_status_line_partial_data() {
485 let json = r#"{
487 "session_id": "test-partial",
488 "model": {"id": "claude-sonnet-4-20250514"}
489 }"#;
490
491 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
492 let session = raw.to_session_domain().expect("should create with defaults");
493
494 assert_eq!(session.id.as_str(), "test-partial");
495 assert!((session.cost.as_usd() - 0.0).abs() < 0.001);
496 assert_eq!(session.context.total_input_tokens.as_u64(), 0);
497 }
498}