Skip to main content

atm_protocol/
parse.rs

1//! Parsing Claude Code JSON structures.
2//!
3//! Based on validated integration testing (Week 1).
4
5use atm_core::{HookEventType, SessionDomain, SessionId, StatusLineData};
6use serde::Deserialize;
7
8/// Raw status line JSON structure from Claude Code.
9///
10/// Based on validated integration testing (Week 1).
11/// All fields except session_id are optional to handle partial updates.
12#[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    /// Process ID of the Claude Code process (injected by status line script via $PPID)
32    #[serde(default)]
33    pub pid: Option<u32>,
34    /// Tmux pane ID (injected by hook script via $TMUX_PANE)
35    #[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    /// Pre-calculated percentage of context window used (0-100), provided by Claude Code
75    #[serde(default)]
76    pub used_percentage: Option<f64>,
77    /// Pre-calculated percentage of context window remaining (0-100), provided by Claude Code
78    #[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    /// Converts raw JSON data to a StatusLineData struct.
102    ///
103    /// Returns None if required fields (model) are missing.
104    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    /// Converts to SessionDomain.
132    /// Returns None if required fields (model) are missing.
133    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    /// Updates an existing SessionDomain with new data.
139    /// Only updates fields that are present in this status line.
140    /// Returns `true` if the working directory changed.
141    pub fn update_session(&self, session: &mut SessionDomain) -> bool {
142        use atm_core::Model;
143
144        // Update model if present (fills in Unknown for discovered/hook-created sessions)
145        if let Some(model) = &self.model {
146            let parsed = Model::from_id(&model.id);
147            session.model = parsed;
148
149            // For unknown models, store display name fallback
150            if parsed.is_unknown() && !model.id.is_empty() {
151                session.model_display_override = Some(
152                    model
153                        .display_name
154                        .clone()
155                        .unwrap_or_else(|| atm_core::derive_display_name(&model.id)),
156                );
157            } else {
158                session.model_display_override = None;
159            }
160        }
161
162        // Build StatusLineData for the update (model_id not used in update)
163        let cost = self.cost.as_ref();
164        let context = self.context_window.as_ref();
165        let current = context.and_then(|c| c.current_usage.as_ref());
166
167        let data = StatusLineData {
168            session_id: self.session_id.clone(),
169            model_id: String::new(),  // Not used in update
170            model_display_name: None, // Not used in update
171            cost_usd: cost.map(|c| c.total_cost_usd).unwrap_or(0.0),
172            total_duration_ms: cost.map(|c| c.total_duration_ms).unwrap_or(0),
173            api_duration_ms: cost.map(|c| c.total_api_duration_ms).unwrap_or(0),
174            lines_added: cost.map(|c| c.total_lines_added).unwrap_or(0),
175            lines_removed: cost.map(|c| c.total_lines_removed).unwrap_or(0),
176            total_input_tokens: context.map(|c| c.total_input_tokens).unwrap_or(0),
177            total_output_tokens: context.map(|c| c.total_output_tokens).unwrap_or(0),
178            context_window_size: context.map(|c| c.context_window_size).unwrap_or(200_000),
179            current_input_tokens: current.map(|c| c.input_tokens).unwrap_or(0),
180            current_output_tokens: current.map(|c| c.output_tokens).unwrap_or(0),
181            cache_creation_tokens: current.map(|c| c.cache_creation_input_tokens).unwrap_or(0),
182            cache_read_tokens: current.map(|c| c.cache_read_input_tokens).unwrap_or(0),
183            cwd: self.cwd.clone(),
184            version: self.version.clone(),
185        };
186
187        session.update_from_status_line(&data)
188    }
189}
190
191/// Raw hook event JSON structure from Claude Code.
192///
193/// Flat structure with all possible fields as Option<T>.
194/// Use typed conversion for domain-layer type safety.
195#[derive(Debug, Clone, Deserialize)]
196pub struct RawHookEvent {
197    // === Common Fields (all events) ===
198    pub session_id: String,
199    pub hook_event_name: String,
200    #[serde(default)]
201    pub cwd: Option<String>,
202    #[serde(default)]
203    pub permission_mode: Option<String>,
204
205    // === Injected by hook script ===
206    #[serde(default)]
207    pub pid: Option<u32>,
208    #[serde(default)]
209    pub tmux_pane: Option<String>,
210
211    // === Tool Events (PreToolUse, PostToolUse, PostToolUseFailure) ===
212    #[serde(default)]
213    pub tool_name: Option<String>,
214    #[serde(default)]
215    pub tool_input: Option<serde_json::Value>,
216    #[serde(default)]
217    pub tool_response: Option<serde_json::Value>,
218    #[serde(default)]
219    pub tool_use_id: Option<String>,
220
221    // === User Prompt (UserPromptSubmit) ===
222    #[serde(default)]
223    pub prompt: Option<String>,
224
225    // === Stop Events (Stop, SubagentStop) ===
226    #[serde(default)]
227    pub stop_hook_active: Option<bool>,
228
229    // === Subagent Events (SubagentStart, SubagentStop) ===
230    #[serde(default)]
231    pub agent_id: Option<String>,
232    #[serde(default)]
233    pub agent_type: Option<String>,
234    #[serde(default)]
235    pub agent_transcript_path: Option<String>,
236
237    // === Session Events (SessionStart, SessionEnd) ===
238    #[serde(default)]
239    pub source: Option<String>,
240    #[serde(default)]
241    pub reason: Option<String>,
242    #[serde(default)]
243    pub model: Option<String>,
244
245    // === Compaction/Setup (PreCompact, Setup) ===
246    #[serde(default)]
247    pub trigger: Option<String>,
248    #[serde(default)]
249    pub custom_instructions: Option<String>,
250
251    // === Notification ===
252    #[serde(default)]
253    pub notification_type: Option<String>,
254    #[serde(default)]
255    pub message: Option<String>,
256}
257
258impl RawHookEvent {
259    /// Parses the hook event type.
260    pub fn event_type(&self) -> Option<HookEventType> {
261        HookEventType::from_event_name(&self.hook_event_name)
262    }
263
264    /// Returns the session ID.
265    pub fn session_id(&self) -> SessionId {
266        SessionId::new(&self.session_id)
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use atm_core::Model;
274
275    #[test]
276    fn test_raw_status_line_parsing() {
277        let json = r#"{
278            "session_id": "test-123",
279            "model": {"id": "claude-opus-4-5-20251101", "display_name": "Opus 4.5"},
280            "cost": {"total_cost_usd": 0.35, "total_duration_ms": 35000},
281            "context_window": {"total_input_tokens": 5000, "context_window_size": 200000}
282        }"#;
283
284        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
285        let session = raw.to_session_domain().expect("should create session");
286
287        assert_eq!(session.id.as_str(), "test-123");
288        assert_eq!(session.model, Model::Opus45);
289        assert!((session.cost.as_usd() - 0.35).abs() < 0.001);
290        assert_eq!(session.context.total_input_tokens.as_u64(), 5000);
291    }
292
293    #[test]
294    fn test_raw_hook_event_parsing() {
295        let json = r#"{
296            "session_id": "test-123",
297            "hook_event_name": "PreToolUse",
298            "tool_name": "Bash"
299        }"#;
300
301        let event: RawHookEvent = serde_json::from_str(json).unwrap();
302        assert_eq!(event.event_type(), Some(HookEventType::PreToolUse));
303        assert_eq!(event.tool_name.as_deref(), Some("Bash"));
304    }
305
306    #[test]
307    fn test_raw_status_line_with_current_usage() {
308        let json = r#"{
309            "session_id": "test-456",
310            "model": {"id": "claude-sonnet-4-20250514"},
311            "cost": {"total_cost_usd": 0.10, "total_duration_ms": 10000},
312            "context_window": {
313                "total_input_tokens": 1000,
314                "total_output_tokens": 500,
315                "context_window_size": 200000,
316                "current_usage": {
317                    "input_tokens": 200,
318                    "output_tokens": 100,
319                    "cache_creation_input_tokens": 50,
320                    "cache_read_input_tokens": 25
321                }
322            }
323        }"#;
324
325        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
326        let session = raw.to_session_domain().expect("should create session");
327
328        assert_eq!(session.context.current_input_tokens.as_u64(), 200);
329        assert_eq!(session.context.cache_creation_tokens.as_u64(), 50);
330    }
331
332    #[test]
333    fn test_raw_status_line_context_from_current_usage() {
334        // Context percentage is calculated from current_usage fields
335        // context_tokens = cache_read + input + cache_creation
336        let json = r#"{
337            "session_id": "test-pct",
338            "model": {"id": "claude-sonnet-4-20250514"},
339            "context_window": {
340                "total_input_tokens": 50000,
341                "total_output_tokens": 10000,
342                "context_window_size": 200000,
343                "current_usage": {
344                    "input_tokens": 1000,
345                    "output_tokens": 500,
346                    "cache_creation_input_tokens": 2000,
347                    "cache_read_input_tokens": 40000
348                }
349            }
350        }"#;
351
352        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
353        let session = raw.to_session_domain().expect("should create session");
354
355        // context_tokens = 40000 + 1000 + 2000 = 43000
356        // percentage = 43000 / 200000 = 21.5%
357        assert_eq!(session.context.context_tokens().as_u64(), 43_000);
358        assert!((session.context.usage_percentage() - 21.5).abs() < 0.01);
359    }
360
361    #[test]
362    fn test_raw_status_line_zero_without_current_usage() {
363        // When current_usage is missing (like after /clear), context should be 0%
364        let json = r#"{
365            "session_id": "test-fallback",
366            "model": {"id": "claude-sonnet-4-20250514"},
367            "context_window": {
368                "total_input_tokens": 50000,
369                "total_output_tokens": 10000,
370                "context_window_size": 200000
371            }
372        }"#;
373
374        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
375        let session = raw.to_session_domain().expect("should create session");
376
377        // No current_usage means context_tokens is 0, so 0%
378        assert_eq!(session.context.context_tokens().as_u64(), 0);
379        assert!((session.context.usage_percentage() - 0.0).abs() < 0.01);
380    }
381
382    #[test]
383    fn test_raw_status_line_missing_model_returns_none() {
384        // Status line without model should not create a session
385        let json = r#"{"session_id": "test-789"}"#;
386
387        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
388        assert!(raw.to_session_domain().is_none());
389    }
390
391    #[test]
392    fn test_raw_hook_event_stop() {
393        let json = r#"{
394            "session_id": "test-123",
395            "hook_event_name": "Stop",
396            "stop_hook_active": true
397        }"#;
398
399        let event: RawHookEvent = serde_json::from_str(json).unwrap();
400        assert_eq!(event.event_type(), Some(HookEventType::Stop));
401        assert_eq!(event.stop_hook_active, Some(true));
402    }
403
404    #[test]
405    fn test_raw_hook_event_user_prompt() {
406        let json = r#"{
407            "session_id": "test-123",
408            "hook_event_name": "UserPromptSubmit",
409            "prompt": "Help me write a function"
410        }"#;
411
412        let event: RawHookEvent = serde_json::from_str(json).unwrap();
413        assert_eq!(event.event_type(), Some(HookEventType::UserPromptSubmit));
414        assert_eq!(event.prompt.as_deref(), Some("Help me write a function"));
415    }
416
417    #[test]
418    fn test_raw_hook_event_subagent_start() {
419        let json = r#"{
420            "session_id": "test-123",
421            "hook_event_name": "SubagentStart",
422            "agent_id": "agent_456",
423            "agent_type": "Explore"
424        }"#;
425
426        let event: RawHookEvent = serde_json::from_str(json).unwrap();
427        assert_eq!(event.event_type(), Some(HookEventType::SubagentStart));
428        assert_eq!(event.agent_id.as_deref(), Some("agent_456"));
429        assert_eq!(event.agent_type.as_deref(), Some("Explore"));
430    }
431
432    #[test]
433    fn test_raw_hook_event_notification() {
434        let json = r#"{
435            "session_id": "test-123",
436            "hook_event_name": "Notification",
437            "notification_type": "permission_prompt",
438            "message": "Allow tool execution?"
439        }"#;
440
441        let event: RawHookEvent = serde_json::from_str(json).unwrap();
442        assert_eq!(event.event_type(), Some(HookEventType::Notification));
443        assert_eq!(
444            event.notification_type.as_deref(),
445            Some("permission_prompt")
446        );
447    }
448
449    #[test]
450    fn test_raw_hook_event_session_start() {
451        let json = r#"{
452            "session_id": "test-123",
453            "hook_event_name": "SessionStart",
454            "source": "resume",
455            "model": "claude-opus-4-5-20251101"
456        }"#;
457
458        let event: RawHookEvent = serde_json::from_str(json).unwrap();
459        assert_eq!(event.event_type(), Some(HookEventType::SessionStart));
460        assert_eq!(event.source.as_deref(), Some("resume"));
461    }
462
463    #[test]
464    fn test_raw_hook_event_pre_compact() {
465        let json = r#"{
466            "session_id": "test-123",
467            "hook_event_name": "PreCompact",
468            "trigger": "auto"
469        }"#;
470
471        let event: RawHookEvent = serde_json::from_str(json).unwrap();
472        assert_eq!(event.event_type(), Some(HookEventType::PreCompact));
473        assert_eq!(event.trigger.as_deref(), Some("auto"));
474    }
475
476    #[test]
477    fn test_update_session_fills_in_model() {
478        use atm_core::{AgentType, SessionDomain};
479
480        // Simulate a session created via discovery (model Unknown)
481        let mut session = SessionDomain::new(
482            atm_core::SessionId::new("test-discovered"),
483            AgentType::GeneralPurpose,
484            Model::Unknown,
485        );
486        assert_eq!(session.model, Model::Unknown);
487
488        // Status line arrives with model info
489        let json = r#"{
490            "session_id": "test-discovered",
491            "model": {"id": "claude-opus-4-5-20251101"},
492            "cost": {"total_cost_usd": 0.50, "total_duration_ms": 10000}
493        }"#;
494
495        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
496        raw.update_session(&mut session);
497
498        // Model should now be filled in
499        assert_eq!(session.model, Model::Opus45);
500        // Known model should not have a display override
501        assert!(session.model_display_override.is_none());
502    }
503
504    #[test]
505    fn test_update_session_unknown_model_with_display_name() {
506        use atm_core::{AgentType, SessionDomain};
507
508        let mut session = SessionDomain::new(
509            atm_core::SessionId::new("test-non-anthropic"),
510            AgentType::GeneralPurpose,
511            Model::Unknown,
512        );
513
514        // Non-Anthropic model with display_name
515        let json = r#"{
516            "session_id": "test-non-anthropic",
517            "model": {"id": "gpt-4o", "display_name": "GPT-4o"}
518        }"#;
519
520        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
521        raw.update_session(&mut session);
522
523        assert_eq!(session.model, Model::Unknown);
524        assert_eq!(session.model_display_override.as_deref(), Some("GPT-4o"));
525    }
526
527    #[test]
528    fn test_update_session_unknown_model_without_display_name() {
529        use atm_core::{AgentType, SessionDomain};
530
531        let mut session = SessionDomain::new(
532            atm_core::SessionId::new("test-unknown"),
533            AgentType::GeneralPurpose,
534            Model::Unknown,
535        );
536
537        // Unknown model without display_name - should derive from ID
538        let json = r#"{
539            "session_id": "test-unknown",
540            "model": {"id": "gemini-1.5-pro"}
541        }"#;
542
543        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
544        raw.update_session(&mut session);
545
546        assert_eq!(session.model, Model::Unknown);
547        assert_eq!(
548            session.model_display_override.as_deref(),
549            Some("gemini-1.5-pro")
550        );
551    }
552
553    #[test]
554    fn test_new_session_opus46() {
555        let json = r#"{
556            "session_id": "test-opus46",
557            "model": {"id": "claude-opus-4-6"}
558        }"#;
559
560        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
561        let session = raw.to_session_domain().expect("should create session");
562
563        assert_eq!(session.model, Model::Opus46);
564        assert!(session.model_display_override.is_none());
565    }
566
567    #[test]
568    fn test_new_session_non_anthropic_model() {
569        let json = r#"{
570            "session_id": "test-gpt",
571            "model": {"id": "gpt-4o", "display_name": "GPT-4o"}
572        }"#;
573
574        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
575        let session = raw.to_session_domain().expect("should create session");
576
577        assert_eq!(session.model, Model::Unknown);
578        assert_eq!(session.model_display_override.as_deref(), Some("GPT-4o"));
579    }
580
581    #[test]
582    fn test_raw_status_line_partial_data() {
583        // Status line with model but no cost/context should create session with defaults
584        let json = r#"{
585            "session_id": "test-partial",
586            "model": {"id": "claude-sonnet-4-20250514"}
587        }"#;
588
589        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
590        let session = raw
591            .to_session_domain()
592            .expect("should create with defaults");
593
594        assert_eq!(session.id.as_str(), "test-partial");
595        assert!((session.cost.as_usd() - 0.0).abs() < 0.001);
596        assert_eq!(session.context.total_input_tokens.as_u64(), 0);
597    }
598}