Skip to main content

atm_protocol/
parse.rs

1//! Parsing of vendor-shaped wire payloads carried by `MessageType` envelopes.
2//!
3//! Currently houses Claude's status-line JSON parser. The
4//! Claude raw hook event vocabulary lives in the `atm-claude-adapter`
5//! crate; pi-shaped wire data lives in `atm-pi-adapter`. Future:
6//! `RawStatusLine` likely also moves to `atm-claude-adapter`.
7
8use atm_core::{SessionDomain, StatusLineData};
9use serde::Deserialize;
10
11/// Raw status line JSON structure from Claude Code.
12///
13/// Based on validated integration testing (Week 1).
14/// All fields except session_id are optional to handle partial updates.
15#[derive(Debug, Clone, Deserialize)]
16pub struct RawStatusLine {
17    pub session_id: String,
18    #[serde(default)]
19    pub transcript_path: Option<String>,
20    #[serde(default)]
21    pub cwd: Option<String>,
22    #[serde(default)]
23    pub model: Option<RawModel>,
24    #[serde(default)]
25    pub workspace: Option<RawWorkspace>,
26    #[serde(default)]
27    pub version: Option<String>,
28    #[serde(default)]
29    pub cost: Option<RawCost>,
30    #[serde(default)]
31    pub context_window: Option<RawContextWindow>,
32    #[serde(default)]
33    pub exceeds_200k_tokens: Option<bool>,
34    /// Process ID of the Claude Code process (injected by status line script via $PPID)
35    #[serde(default)]
36    pub pid: Option<u32>,
37    /// Tmux pane ID (injected by hook script via $TMUX_PANE)
38    #[serde(default)]
39    pub tmux_pane: Option<String>,
40}
41
42#[derive(Debug, Clone, Deserialize)]
43pub struct RawModel {
44    pub id: String,
45    #[serde(default)]
46    pub display_name: Option<String>,
47}
48
49#[derive(Debug, Clone, Deserialize)]
50pub struct RawWorkspace {
51    #[serde(default)]
52    pub current_dir: Option<String>,
53    #[serde(default)]
54    pub project_dir: Option<String>,
55}
56
57#[derive(Debug, Clone, Deserialize)]
58pub struct RawCost {
59    pub total_cost_usd: f64,
60    pub total_duration_ms: u64,
61    #[serde(default)]
62    pub total_api_duration_ms: u64,
63    #[serde(default)]
64    pub total_lines_added: u64,
65    #[serde(default)]
66    pub total_lines_removed: u64,
67}
68
69#[derive(Debug, Clone, Deserialize)]
70pub struct RawContextWindow {
71    #[serde(default)]
72    pub total_input_tokens: u64,
73    #[serde(default)]
74    pub total_output_tokens: u64,
75    #[serde(default = "default_context_window_size")]
76    pub context_window_size: u32,
77    /// Pre-calculated percentage of context window used (0-100), provided by Claude Code
78    #[serde(default)]
79    pub used_percentage: Option<f64>,
80    /// Pre-calculated percentage of context window remaining (0-100), provided by Claude Code
81    #[serde(default)]
82    pub remaining_percentage: Option<f64>,
83    #[serde(default)]
84    pub current_usage: Option<RawCurrentUsage>,
85}
86
87fn default_context_window_size() -> u32 {
88    200_000
89}
90
91#[derive(Debug, Clone, Deserialize)]
92pub struct RawCurrentUsage {
93    #[serde(default)]
94    pub input_tokens: u64,
95    #[serde(default)]
96    pub output_tokens: u64,
97    #[serde(default)]
98    pub cache_creation_input_tokens: u64,
99    #[serde(default)]
100    pub cache_read_input_tokens: u64,
101}
102
103impl RawStatusLine {
104    /// Converts raw JSON data to a StatusLineData struct.
105    ///
106    /// Returns None if required fields (model) are missing.
107    pub fn to_status_line_data(&self) -> Option<StatusLineData> {
108        let model = self.model.as_ref()?;
109        let cost = self.cost.as_ref();
110        let context = self.context_window.as_ref();
111        let current = context.and_then(|c| c.current_usage.as_ref());
112
113        Some(StatusLineData {
114            session_id: self.session_id.clone(),
115            model_id: model.id.clone(),
116            model_display_name: model.display_name.clone(),
117            cost_usd: cost.map(|c| c.total_cost_usd).unwrap_or(0.0),
118            total_duration_ms: cost.map(|c| c.total_duration_ms).unwrap_or(0),
119            api_duration_ms: cost.map(|c| c.total_api_duration_ms).unwrap_or(0),
120            lines_added: cost.map(|c| c.total_lines_added).unwrap_or(0),
121            lines_removed: cost.map(|c| c.total_lines_removed).unwrap_or(0),
122            total_input_tokens: context.map(|c| c.total_input_tokens).unwrap_or(0),
123            total_output_tokens: context.map(|c| c.total_output_tokens).unwrap_or(0),
124            context_window_size: context.map(|c| c.context_window_size).unwrap_or(200_000),
125            current_input_tokens: current.map(|c| c.input_tokens).unwrap_or(0),
126            current_output_tokens: current.map(|c| c.output_tokens).unwrap_or(0),
127            cache_creation_tokens: current.map(|c| c.cache_creation_input_tokens).unwrap_or(0),
128            cache_read_tokens: current.map(|c| c.cache_read_input_tokens).unwrap_or(0),
129            cwd: self.cwd.clone(),
130            version: self.version.clone(),
131        })
132    }
133
134    /// Converts to SessionDomain.
135    /// Returns None if required fields (model) are missing.
136    pub fn to_session_domain(&self) -> Option<SessionDomain> {
137        let data = self.to_status_line_data()?;
138        Some(SessionDomain::from_status_line(&data))
139    }
140
141    /// Updates an existing SessionDomain with new data.
142    /// Only updates fields that are present in this status line.
143    /// Returns `true` if the working directory changed.
144    pub fn update_session(&self, session: &mut SessionDomain) -> bool {
145        use atm_core::Model;
146
147        // Update model if present (fills in Unknown for discovered/hook-created sessions)
148        if let Some(model) = &self.model {
149            let parsed = Model::from_id(&model.id);
150            session.model = parsed;
151
152            // For unknown models, store display name fallback
153            if parsed.is_unknown() && !model.id.is_empty() {
154                session.model_display_override = Some(
155                    model
156                        .display_name
157                        .clone()
158                        .unwrap_or_else(|| atm_core::derive_display_name(&model.id)),
159                );
160            } else {
161                session.model_display_override = None;
162            }
163        }
164
165        // Build StatusLineData for the update (model_id not used in update)
166        let cost = self.cost.as_ref();
167        let context = self.context_window.as_ref();
168        let current = context.and_then(|c| c.current_usage.as_ref());
169
170        let data = StatusLineData {
171            session_id: self.session_id.clone(),
172            model_id: String::new(),  // Not used in update
173            model_display_name: None, // Not used in update
174            cost_usd: cost.map(|c| c.total_cost_usd).unwrap_or(0.0),
175            total_duration_ms: cost.map(|c| c.total_duration_ms).unwrap_or(0),
176            api_duration_ms: cost.map(|c| c.total_api_duration_ms).unwrap_or(0),
177            lines_added: cost.map(|c| c.total_lines_added).unwrap_or(0),
178            lines_removed: cost.map(|c| c.total_lines_removed).unwrap_or(0),
179            total_input_tokens: context.map(|c| c.total_input_tokens).unwrap_or(0),
180            total_output_tokens: context.map(|c| c.total_output_tokens).unwrap_or(0),
181            context_window_size: context.map(|c| c.context_window_size).unwrap_or(200_000),
182            current_input_tokens: current.map(|c| c.input_tokens).unwrap_or(0),
183            current_output_tokens: current.map(|c| c.output_tokens).unwrap_or(0),
184            cache_creation_tokens: current.map(|c| c.cache_creation_input_tokens).unwrap_or(0),
185            cache_read_tokens: current.map(|c| c.cache_read_input_tokens).unwrap_or(0),
186            cwd: self.cwd.clone(),
187            version: self.version.clone(),
188        };
189
190        session.update_from_status_line(&data)
191    }
192}
193
194// `RawHookEvent` and the Claude→LifecycleEvent translation moved to the
195// `atm-claude-adapter` crate (see `atm_claude_adapter::wire::RawHookEvent`
196// and `atm_claude_adapter::translate`).
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use atm_core::Model;
202
203    #[test]
204    fn test_raw_status_line_parsing() {
205        let json = r#"{
206            "session_id": "test-123",
207            "model": {"id": "claude-opus-4-5-20251101", "display_name": "Opus 4.5"},
208            "cost": {"total_cost_usd": 0.35, "total_duration_ms": 35000},
209            "context_window": {"total_input_tokens": 5000, "context_window_size": 200000}
210        }"#;
211
212        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
213        let session = raw.to_session_domain().expect("should create session");
214
215        assert_eq!(session.id.as_str(), "test-123");
216        assert_eq!(session.model, Model::Opus45);
217        assert!((session.cost.as_usd() - 0.35).abs() < 0.001);
218        assert_eq!(session.context.total_input_tokens.as_u64(), 5000);
219    }
220
221    #[test]
222    fn test_raw_status_line_with_current_usage() {
223        let json = r#"{
224            "session_id": "test-456",
225            "model": {"id": "claude-sonnet-4-20250514"},
226            "cost": {"total_cost_usd": 0.10, "total_duration_ms": 10000},
227            "context_window": {
228                "total_input_tokens": 1000,
229                "total_output_tokens": 500,
230                "context_window_size": 200000,
231                "current_usage": {
232                    "input_tokens": 200,
233                    "output_tokens": 100,
234                    "cache_creation_input_tokens": 50,
235                    "cache_read_input_tokens": 25
236                }
237            }
238        }"#;
239
240        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
241        let session = raw.to_session_domain().expect("should create session");
242
243        assert_eq!(session.context.current_input_tokens.as_u64(), 200);
244        assert_eq!(session.context.cache_creation_tokens.as_u64(), 50);
245    }
246
247    #[test]
248    fn test_raw_status_line_context_from_current_usage() {
249        // Context percentage is calculated from current_usage fields
250        // context_tokens = cache_read + input + cache_creation
251        let json = r#"{
252            "session_id": "test-pct",
253            "model": {"id": "claude-sonnet-4-20250514"},
254            "context_window": {
255                "total_input_tokens": 50000,
256                "total_output_tokens": 10000,
257                "context_window_size": 200000,
258                "current_usage": {
259                    "input_tokens": 1000,
260                    "output_tokens": 500,
261                    "cache_creation_input_tokens": 2000,
262                    "cache_read_input_tokens": 40000
263                }
264            }
265        }"#;
266
267        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
268        let session = raw.to_session_domain().expect("should create session");
269
270        // context_tokens = 40000 + 1000 + 2000 = 43000
271        // percentage = 43000 / 200000 = 21.5%
272        assert_eq!(session.context.context_tokens().as_u64(), 43_000);
273        assert!((session.context.usage_percentage() - 21.5).abs() < 0.01);
274    }
275
276    #[test]
277    fn test_raw_status_line_zero_without_current_usage() {
278        // When current_usage is missing (like after /clear), context should be 0%
279        let json = r#"{
280            "session_id": "test-fallback",
281            "model": {"id": "claude-sonnet-4-20250514"},
282            "context_window": {
283                "total_input_tokens": 50000,
284                "total_output_tokens": 10000,
285                "context_window_size": 200000
286            }
287        }"#;
288
289        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
290        let session = raw.to_session_domain().expect("should create session");
291
292        // No current_usage means context_tokens is 0, so 0%
293        assert_eq!(session.context.context_tokens().as_u64(), 0);
294        assert!((session.context.usage_percentage() - 0.0).abs() < 0.01);
295    }
296
297    #[test]
298    fn test_raw_status_line_missing_model_returns_none() {
299        // Status line without model should not create a session
300        let json = r#"{"session_id": "test-789"}"#;
301
302        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
303        assert!(raw.to_session_domain().is_none());
304    }
305
306    #[test]
307    fn test_update_session_fills_in_model() {
308        use atm_core::{AgentType, SessionDomain};
309
310        // Simulate a session created via discovery (model Unknown)
311        let mut session = SessionDomain::new(
312            atm_core::SessionId::new("test-discovered"),
313            AgentType::GeneralPurpose,
314            Model::Unknown,
315        );
316        assert_eq!(session.model, Model::Unknown);
317
318        // Status line arrives with model info
319        let json = r#"{
320            "session_id": "test-discovered",
321            "model": {"id": "claude-opus-4-5-20251101"},
322            "cost": {"total_cost_usd": 0.50, "total_duration_ms": 10000}
323        }"#;
324
325        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
326        raw.update_session(&mut session);
327
328        // Model should now be filled in
329        assert_eq!(session.model, Model::Opus45);
330        // Known model should not have a display override
331        assert!(session.model_display_override.is_none());
332    }
333
334    #[test]
335    fn test_update_session_unknown_model_with_display_name() {
336        use atm_core::{AgentType, SessionDomain};
337
338        let mut session = SessionDomain::new(
339            atm_core::SessionId::new("test-non-anthropic"),
340            AgentType::GeneralPurpose,
341            Model::Unknown,
342        );
343
344        // Non-Anthropic model with display_name
345        let json = r#"{
346            "session_id": "test-non-anthropic",
347            "model": {"id": "gpt-4o", "display_name": "GPT-4o"}
348        }"#;
349
350        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
351        raw.update_session(&mut session);
352
353        assert_eq!(session.model, Model::Unknown);
354        assert_eq!(session.model_display_override.as_deref(), Some("GPT-4o"));
355    }
356
357    #[test]
358    fn test_update_session_unknown_model_without_display_name() {
359        use atm_core::{AgentType, SessionDomain};
360
361        let mut session = SessionDomain::new(
362            atm_core::SessionId::new("test-unknown"),
363            AgentType::GeneralPurpose,
364            Model::Unknown,
365        );
366
367        // Unknown model without display_name - should derive from ID
368        let json = r#"{
369            "session_id": "test-unknown",
370            "model": {"id": "gemini-1.5-pro"}
371        }"#;
372
373        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
374        raw.update_session(&mut session);
375
376        assert_eq!(session.model, Model::Unknown);
377        assert_eq!(
378            session.model_display_override.as_deref(),
379            Some("gemini-1.5-pro")
380        );
381    }
382
383    #[test]
384    fn test_new_session_opus46() {
385        let json = r#"{
386            "session_id": "test-opus46",
387            "model": {"id": "claude-opus-4-6"}
388        }"#;
389
390        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
391        let session = raw.to_session_domain().expect("should create session");
392
393        assert_eq!(session.model, Model::Opus46);
394        assert!(session.model_display_override.is_none());
395    }
396
397    #[test]
398    fn test_new_session_non_anthropic_model() {
399        let json = r#"{
400            "session_id": "test-gpt",
401            "model": {"id": "gpt-4o", "display_name": "GPT-4o"}
402        }"#;
403
404        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
405        let session = raw.to_session_domain().expect("should create session");
406
407        assert_eq!(session.model, Model::Unknown);
408        assert_eq!(session.model_display_override.as_deref(), Some("GPT-4o"));
409    }
410
411    #[test]
412    fn test_raw_status_line_partial_data() {
413        // Status line with model but no cost/context should create session with defaults
414        let json = r#"{
415            "session_id": "test-partial",
416            "model": {"id": "claude-sonnet-4-20250514"}
417        }"#;
418
419        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
420        let session = raw
421            .to_session_domain()
422            .expect("should create with defaults");
423
424        assert_eq!(session.id.as_str(), "test-partial");
425        assert!((session.cost.as_usd() - 0.0).abs() < 0.001);
426        assert_eq!(session.context.total_input_tokens.as_u64(), 0);
427    }
428}