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 model_display_name: model.display_name.clone(),
114 cost_usd: cost.map(|c| c.total_cost_usd).unwrap_or(0.0),
115 total_duration_ms: cost.map(|c| c.total_duration_ms).unwrap_or(0),
116 api_duration_ms: cost.map(|c| c.total_api_duration_ms).unwrap_or(0),
117 lines_added: cost.map(|c| c.total_lines_added).unwrap_or(0),
118 lines_removed: cost.map(|c| c.total_lines_removed).unwrap_or(0),
119 total_input_tokens: context.map(|c| c.total_input_tokens).unwrap_or(0),
120 total_output_tokens: context.map(|c| c.total_output_tokens).unwrap_or(0),
121 context_window_size: context.map(|c| c.context_window_size).unwrap_or(200_000),
122 current_input_tokens: current.map(|c| c.input_tokens).unwrap_or(0),
123 current_output_tokens: current.map(|c| c.output_tokens).unwrap_or(0),
124 cache_creation_tokens: current.map(|c| c.cache_creation_input_tokens).unwrap_or(0),
125 cache_read_tokens: current.map(|c| c.cache_read_input_tokens).unwrap_or(0),
126 cwd: self.cwd.clone(),
127 version: self.version.clone(),
128 })
129 }
130
131 pub fn to_session_domain(&self) -> Option<SessionDomain> {
134 let data = self.to_status_line_data()?;
135 Some(SessionDomain::from_status_line(&data))
136 }
137
138 pub fn update_session(&self, session: &mut SessionDomain) {
141 use atm_core::Model;
142
143 if let Some(model) = &self.model {
145 let parsed = Model::from_id(&model.id);
146 session.model = parsed;
147
148 if parsed.is_unknown() && !model.id.is_empty() {
150 session.model_display_override = Some(
151 model
152 .display_name
153 .clone()
154 .unwrap_or_else(|| atm_core::derive_display_name(&model.id)),
155 );
156 } else {
157 session.model_display_override = None;
158 }
159 }
160
161 let cost = self.cost.as_ref();
163 let context = self.context_window.as_ref();
164 let current = context.and_then(|c| c.current_usage.as_ref());
165
166 let data = StatusLineData {
167 session_id: self.session_id.clone(),
168 model_id: String::new(), model_display_name: None, cost_usd: cost.map(|c| c.total_cost_usd).unwrap_or(0.0),
171 total_duration_ms: cost.map(|c| c.total_duration_ms).unwrap_or(0),
172 api_duration_ms: cost.map(|c| c.total_api_duration_ms).unwrap_or(0),
173 lines_added: cost.map(|c| c.total_lines_added).unwrap_or(0),
174 lines_removed: cost.map(|c| c.total_lines_removed).unwrap_or(0),
175 total_input_tokens: context.map(|c| c.total_input_tokens).unwrap_or(0),
176 total_output_tokens: context.map(|c| c.total_output_tokens).unwrap_or(0),
177 context_window_size: context.map(|c| c.context_window_size).unwrap_or(200_000),
178 current_input_tokens: current.map(|c| c.input_tokens).unwrap_or(0),
179 current_output_tokens: current.map(|c| c.output_tokens).unwrap_or(0),
180 cache_creation_tokens: current.map(|c| c.cache_creation_input_tokens).unwrap_or(0),
181 cache_read_tokens: current.map(|c| c.cache_read_input_tokens).unwrap_or(0),
182 cwd: self.cwd.clone(),
183 version: self.version.clone(),
184 };
185
186 session.update_from_status_line(&data);
187 }
188}
189
190#[derive(Debug, Clone, Deserialize)]
195pub struct RawHookEvent {
196 pub session_id: String,
198 pub hook_event_name: String,
199 #[serde(default)]
200 pub cwd: Option<String>,
201 #[serde(default)]
202 pub permission_mode: Option<String>,
203
204 #[serde(default)]
206 pub pid: Option<u32>,
207 #[serde(default)]
208 pub tmux_pane: Option<String>,
209
210 #[serde(default)]
212 pub tool_name: Option<String>,
213 #[serde(default)]
214 pub tool_input: Option<serde_json::Value>,
215 #[serde(default)]
216 pub tool_response: Option<serde_json::Value>,
217 #[serde(default)]
218 pub tool_use_id: Option<String>,
219
220 #[serde(default)]
222 pub prompt: Option<String>,
223
224 #[serde(default)]
226 pub stop_hook_active: Option<bool>,
227
228 #[serde(default)]
230 pub agent_id: Option<String>,
231 #[serde(default)]
232 pub agent_type: Option<String>,
233 #[serde(default)]
234 pub agent_transcript_path: Option<String>,
235
236 #[serde(default)]
238 pub source: Option<String>,
239 #[serde(default)]
240 pub reason: Option<String>,
241 #[serde(default)]
242 pub model: Option<String>,
243
244 #[serde(default)]
246 pub trigger: Option<String>,
247 #[serde(default)]
248 pub custom_instructions: Option<String>,
249
250 #[serde(default)]
252 pub notification_type: Option<String>,
253 #[serde(default)]
254 pub message: Option<String>,
255}
256
257impl RawHookEvent {
258 pub fn event_type(&self) -> Option<HookEventType> {
260 HookEventType::from_event_name(&self.hook_event_name)
261 }
262
263 pub fn session_id(&self) -> SessionId {
265 SessionId::new(&self.session_id)
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use atm_core::Model;
273
274 #[test]
275 fn test_raw_status_line_parsing() {
276 let json = r#"{
277 "session_id": "test-123",
278 "model": {"id": "claude-opus-4-5-20251101", "display_name": "Opus 4.5"},
279 "cost": {"total_cost_usd": 0.35, "total_duration_ms": 35000},
280 "context_window": {"total_input_tokens": 5000, "context_window_size": 200000}
281 }"#;
282
283 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
284 let session = raw.to_session_domain().expect("should create session");
285
286 assert_eq!(session.id.as_str(), "test-123");
287 assert_eq!(session.model, Model::Opus45);
288 assert!((session.cost.as_usd() - 0.35).abs() < 0.001);
289 assert_eq!(session.context.total_input_tokens.as_u64(), 5000);
290 }
291
292 #[test]
293 fn test_raw_hook_event_parsing() {
294 let json = r#"{
295 "session_id": "test-123",
296 "hook_event_name": "PreToolUse",
297 "tool_name": "Bash"
298 }"#;
299
300 let event: RawHookEvent = serde_json::from_str(json).unwrap();
301 assert_eq!(event.event_type(), Some(HookEventType::PreToolUse));
302 assert_eq!(event.tool_name.as_deref(), Some("Bash"));
303 }
304
305 #[test]
306 fn test_raw_status_line_with_current_usage() {
307 let json = r#"{
308 "session_id": "test-456",
309 "model": {"id": "claude-sonnet-4-20250514"},
310 "cost": {"total_cost_usd": 0.10, "total_duration_ms": 10000},
311 "context_window": {
312 "total_input_tokens": 1000,
313 "total_output_tokens": 500,
314 "context_window_size": 200000,
315 "current_usage": {
316 "input_tokens": 200,
317 "output_tokens": 100,
318 "cache_creation_input_tokens": 50,
319 "cache_read_input_tokens": 25
320 }
321 }
322 }"#;
323
324 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
325 let session = raw.to_session_domain().expect("should create session");
326
327 assert_eq!(session.context.current_input_tokens.as_u64(), 200);
328 assert_eq!(session.context.cache_creation_tokens.as_u64(), 50);
329 }
330
331 #[test]
332 fn test_raw_status_line_context_from_current_usage() {
333 let json = r#"{
336 "session_id": "test-pct",
337 "model": {"id": "claude-sonnet-4-20250514"},
338 "context_window": {
339 "total_input_tokens": 50000,
340 "total_output_tokens": 10000,
341 "context_window_size": 200000,
342 "current_usage": {
343 "input_tokens": 1000,
344 "output_tokens": 500,
345 "cache_creation_input_tokens": 2000,
346 "cache_read_input_tokens": 40000
347 }
348 }
349 }"#;
350
351 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
352 let session = raw.to_session_domain().expect("should create session");
353
354 assert_eq!(session.context.context_tokens().as_u64(), 43_000);
357 assert!((session.context.usage_percentage() - 21.5).abs() < 0.01);
358 }
359
360 #[test]
361 fn test_raw_status_line_zero_without_current_usage() {
362 let json = r#"{
364 "session_id": "test-fallback",
365 "model": {"id": "claude-sonnet-4-20250514"},
366 "context_window": {
367 "total_input_tokens": 50000,
368 "total_output_tokens": 10000,
369 "context_window_size": 200000
370 }
371 }"#;
372
373 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
374 let session = raw.to_session_domain().expect("should create session");
375
376 assert_eq!(session.context.context_tokens().as_u64(), 0);
378 assert!((session.context.usage_percentage() - 0.0).abs() < 0.01);
379 }
380
381 #[test]
382 fn test_raw_status_line_missing_model_returns_none() {
383 let json = r#"{"session_id": "test-789"}"#;
385
386 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
387 assert!(raw.to_session_domain().is_none());
388 }
389
390 #[test]
391 fn test_raw_hook_event_stop() {
392 let json = r#"{
393 "session_id": "test-123",
394 "hook_event_name": "Stop",
395 "stop_hook_active": true
396 }"#;
397
398 let event: RawHookEvent = serde_json::from_str(json).unwrap();
399 assert_eq!(event.event_type(), Some(HookEventType::Stop));
400 assert_eq!(event.stop_hook_active, Some(true));
401 }
402
403 #[test]
404 fn test_raw_hook_event_user_prompt() {
405 let json = r#"{
406 "session_id": "test-123",
407 "hook_event_name": "UserPromptSubmit",
408 "prompt": "Help me write a function"
409 }"#;
410
411 let event: RawHookEvent = serde_json::from_str(json).unwrap();
412 assert_eq!(event.event_type(), Some(HookEventType::UserPromptSubmit));
413 assert_eq!(event.prompt.as_deref(), Some("Help me write a function"));
414 }
415
416 #[test]
417 fn test_raw_hook_event_subagent_start() {
418 let json = r#"{
419 "session_id": "test-123",
420 "hook_event_name": "SubagentStart",
421 "agent_id": "agent_456",
422 "agent_type": "Explore"
423 }"#;
424
425 let event: RawHookEvent = serde_json::from_str(json).unwrap();
426 assert_eq!(event.event_type(), Some(HookEventType::SubagentStart));
427 assert_eq!(event.agent_id.as_deref(), Some("agent_456"));
428 assert_eq!(event.agent_type.as_deref(), Some("Explore"));
429 }
430
431 #[test]
432 fn test_raw_hook_event_notification() {
433 let json = r#"{
434 "session_id": "test-123",
435 "hook_event_name": "Notification",
436 "notification_type": "permission_prompt",
437 "message": "Allow tool execution?"
438 }"#;
439
440 let event: RawHookEvent = serde_json::from_str(json).unwrap();
441 assert_eq!(event.event_type(), Some(HookEventType::Notification));
442 assert_eq!(
443 event.notification_type.as_deref(),
444 Some("permission_prompt")
445 );
446 }
447
448 #[test]
449 fn test_raw_hook_event_session_start() {
450 let json = r#"{
451 "session_id": "test-123",
452 "hook_event_name": "SessionStart",
453 "source": "resume",
454 "model": "claude-opus-4-5-20251101"
455 }"#;
456
457 let event: RawHookEvent = serde_json::from_str(json).unwrap();
458 assert_eq!(event.event_type(), Some(HookEventType::SessionStart));
459 assert_eq!(event.source.as_deref(), Some("resume"));
460 }
461
462 #[test]
463 fn test_raw_hook_event_pre_compact() {
464 let json = r#"{
465 "session_id": "test-123",
466 "hook_event_name": "PreCompact",
467 "trigger": "auto"
468 }"#;
469
470 let event: RawHookEvent = serde_json::from_str(json).unwrap();
471 assert_eq!(event.event_type(), Some(HookEventType::PreCompact));
472 assert_eq!(event.trigger.as_deref(), Some("auto"));
473 }
474
475 #[test]
476 fn test_update_session_fills_in_model() {
477 use atm_core::{AgentType, SessionDomain};
478
479 let mut session = SessionDomain::new(
481 atm_core::SessionId::new("test-discovered"),
482 AgentType::GeneralPurpose,
483 Model::Unknown,
484 );
485 assert_eq!(session.model, Model::Unknown);
486
487 let json = r#"{
489 "session_id": "test-discovered",
490 "model": {"id": "claude-opus-4-5-20251101"},
491 "cost": {"total_cost_usd": 0.50, "total_duration_ms": 10000}
492 }"#;
493
494 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
495 raw.update_session(&mut session);
496
497 assert_eq!(session.model, Model::Opus45);
499 assert!(session.model_display_override.is_none());
501 }
502
503 #[test]
504 fn test_update_session_unknown_model_with_display_name() {
505 use atm_core::{AgentType, SessionDomain};
506
507 let mut session = SessionDomain::new(
508 atm_core::SessionId::new("test-non-anthropic"),
509 AgentType::GeneralPurpose,
510 Model::Unknown,
511 );
512
513 let json = r#"{
515 "session_id": "test-non-anthropic",
516 "model": {"id": "gpt-4o", "display_name": "GPT-4o"}
517 }"#;
518
519 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
520 raw.update_session(&mut session);
521
522 assert_eq!(session.model, Model::Unknown);
523 assert_eq!(session.model_display_override.as_deref(), Some("GPT-4o"));
524 }
525
526 #[test]
527 fn test_update_session_unknown_model_without_display_name() {
528 use atm_core::{AgentType, SessionDomain};
529
530 let mut session = SessionDomain::new(
531 atm_core::SessionId::new("test-unknown"),
532 AgentType::GeneralPurpose,
533 Model::Unknown,
534 );
535
536 let json = r#"{
538 "session_id": "test-unknown",
539 "model": {"id": "gemini-1.5-pro"}
540 }"#;
541
542 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
543 raw.update_session(&mut session);
544
545 assert_eq!(session.model, Model::Unknown);
546 assert_eq!(
547 session.model_display_override.as_deref(),
548 Some("gemini-1.5-pro")
549 );
550 }
551
552 #[test]
553 fn test_new_session_opus46() {
554 let json = r#"{
555 "session_id": "test-opus46",
556 "model": {"id": "claude-opus-4-6"}
557 }"#;
558
559 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
560 let session = raw.to_session_domain().expect("should create session");
561
562 assert_eq!(session.model, Model::Opus46);
563 assert!(session.model_display_override.is_none());
564 }
565
566 #[test]
567 fn test_new_session_non_anthropic_model() {
568 let json = r#"{
569 "session_id": "test-gpt",
570 "model": {"id": "gpt-4o", "display_name": "GPT-4o"}
571 }"#;
572
573 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
574 let session = raw.to_session_domain().expect("should create session");
575
576 assert_eq!(session.model, Model::Unknown);
577 assert_eq!(session.model_display_override.as_deref(), Some("GPT-4o"));
578 }
579
580 #[test]
581 fn test_raw_status_line_partial_data() {
582 let json = r#"{
584 "session_id": "test-partial",
585 "model": {"id": "claude-sonnet-4-20250514"}
586 }"#;
587
588 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
589 let session = raw
590 .to_session_domain()
591 .expect("should create with defaults");
592
593 assert_eq!(session.id.as_str(), "test-partial");
594 assert!((session.cost.as_usd() - 0.0).abs() < 0.001);
595 assert_eq!(session.context.total_input_tokens.as_u64(), 0);
596 }
597}