Skip to main content

chub_core/team/tracking/
session_state.rs

1//! Session state management — compatible with entire.io's session state format.
2//!
3//! State files stored at `.git/entire-sessions/<session-id>.json` for
4//! cross-tool compatibility.
5
6use std::collections::HashSet;
7use std::fs;
8use std::path::PathBuf;
9
10use serde::{Deserialize, Serialize};
11
12use super::types::{CheckpointID, Phase, PromptAttribution, TokenUsage};
13use crate::team::project::find_project_root;
14use crate::util::now_iso8601;
15
16// ---------------------------------------------------------------------------
17// Session state — entire.io compatible
18// ---------------------------------------------------------------------------
19
20/// Session state persisted at `.git/entire-sessions/<session-id>.json`.
21/// Field names and JSON format match entire.io's `session.State`.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct SessionState {
25    /// entire.io uses `sessionID` (camelCase with capital ID).
26    #[serde(rename = "sessionID")]
27    pub session_id: String,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub cli_version: Option<String>,
30    #[serde(default)]
31    pub base_commit: String,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub attribution_base_commit: Option<String>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub worktree_path: Option<String>,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    #[serde(rename = "worktreeID")]
38    pub worktree_id: Option<String>,
39    pub started_at: String,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub ended_at: Option<String>,
42    #[serde(default)]
43    pub phase: Phase,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    #[serde(rename = "turnID")]
46    pub turn_id: Option<String>,
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    #[serde(rename = "turnCheckpointIDs")]
49    pub turn_checkpoint_ids: Vec<String>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub last_interaction_time: Option<String>,
52    #[serde(default)]
53    pub step_count: i32,
54    #[serde(default)]
55    pub checkpoint_transcript_start: i64,
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub untracked_files_at_start: Vec<String>,
58    #[serde(default, skip_serializing_if = "Vec::is_empty")]
59    pub files_touched: Vec<String>,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    #[serde(rename = "lastCheckpointID")]
62    pub last_checkpoint_id: Option<CheckpointID>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub agent_type: Option<String>,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub token_usage: Option<TokenUsage>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub transcript_identifier_at_start: Option<String>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub transcript_path: Option<String>,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub first_prompt: Option<String>,
73    #[serde(default, skip_serializing_if = "Vec::is_empty")]
74    pub prompt_attributions: Vec<PromptAttribution>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub pending_prompt_attribution: Option<PromptAttribution>,
77
78    // --- chub extensions (not in entire.io, skipped if empty) ---
79    #[serde(default, skip_serializing_if = "HashSet::is_empty")]
80    pub tools_used: HashSet<String>,
81    #[serde(default, skip_serializing_if = "is_zero_i32")]
82    pub tool_calls: i32,
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub commits: Vec<String>,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub est_cost_usd: Option<f64>,
87}
88
89fn is_zero_i32(v: &i32) -> bool {
90    *v == 0
91}
92
93impl SessionState {
94    /// Create a new session state.
95    pub fn new(agent: &str, _model: Option<&str>) -> Self {
96        let base_commit = get_head_commit().unwrap_or_default();
97        Self {
98            session_id: generate_session_id(),
99            cli_version: Some(env!("CARGO_PKG_VERSION").to_string()),
100            base_commit: base_commit.clone(),
101            attribution_base_commit: Some(base_commit),
102            worktree_path: None,
103            worktree_id: None,
104            started_at: now_iso8601(),
105            ended_at: None,
106            phase: Phase::Active,
107            turn_id: None,
108            turn_checkpoint_ids: Vec::new(),
109            last_interaction_time: Some(now_iso8601()),
110            step_count: 0,
111            checkpoint_transcript_start: 0,
112            untracked_files_at_start: Vec::new(),
113            files_touched: Vec::new(),
114            last_checkpoint_id: None,
115            agent_type: Some(agent_type_display(agent)),
116            token_usage: Some(TokenUsage::default()),
117            transcript_identifier_at_start: None,
118            transcript_path: None,
119            first_prompt: None,
120            prompt_attributions: Vec::new(),
121            pending_prompt_attribution: None,
122            tools_used: HashSet::new(),
123            tool_calls: 0,
124            commits: Vec::new(),
125            est_cost_usd: None,
126        }
127    }
128}
129
130// ---------------------------------------------------------------------------
131// Phase state machine
132// ---------------------------------------------------------------------------
133
134/// Events that drive session phase transitions.
135#[derive(Debug, Clone)]
136pub enum SessionEvent {
137    SessionStart,
138    TurnStart,
139    TurnEnd,
140    GitCommit,
141    SessionStop,
142    Compaction,
143}
144
145impl SessionState {
146    /// Apply a state machine event. Returns true if the transition was valid.
147    pub fn apply_event(&mut self, event: SessionEvent) -> bool {
148        let now = now_iso8601();
149        match (&self.phase, event) {
150            // IDLE → ACTIVE on turn start
151            (Phase::Idle, SessionEvent::TurnStart) => {
152                self.phase = Phase::Active;
153                self.last_interaction_time = Some(now);
154                self.step_count += 1;
155                true
156            }
157            // IDLE → ENDED on session stop
158            (Phase::Idle, SessionEvent::SessionStop) => {
159                self.phase = Phase::Ended;
160                self.ended_at = Some(now.clone());
161                self.last_interaction_time = Some(now);
162                true
163            }
164            // IDLE + git commit → stay IDLE (condense)
165            (Phase::Idle, SessionEvent::GitCommit) => {
166                self.last_interaction_time = Some(now);
167                true
168            }
169            // ACTIVE → IDLE on turn end
170            (Phase::Active, SessionEvent::TurnEnd) => {
171                self.phase = Phase::Idle;
172                self.last_interaction_time = Some(now);
173                true
174            }
175            // ACTIVE + git commit → stay ACTIVE (condense)
176            (Phase::Active, SessionEvent::GitCommit) => {
177                self.last_interaction_time = Some(now);
178                true
179            }
180            // ACTIVE + compaction → stay ACTIVE (save checkpoint)
181            (Phase::Active, SessionEvent::Compaction) => {
182                self.last_interaction_time = Some(now);
183                true
184            }
185            // ACTIVE → stop directly
186            (Phase::Active, SessionEvent::SessionStop) => {
187                self.phase = Phase::Ended;
188                self.ended_at = Some(now.clone());
189                self.last_interaction_time = Some(now);
190                true
191            }
192            // ENDED + turn start → re-activate
193            (Phase::Ended, SessionEvent::TurnStart) => {
194                self.phase = Phase::Active;
195                self.ended_at = None;
196                self.last_interaction_time = Some(now);
197                self.step_count += 1;
198                true
199            }
200            // ENDED + git commit with files → condense
201            (Phase::Ended, SessionEvent::GitCommit) => {
202                if !self.files_touched.is_empty() {
203                    self.last_interaction_time = Some(now);
204                }
205                true
206            }
207            _ => false,
208        }
209    }
210
211    /// Add a file to the touched set. Absolute paths are relativized to the project root.
212    pub fn touch_file(&mut self, path: &str) {
213        let relative = crate::team::hooks::relativize_path(path);
214        let normalized = relative.replace('\\', "/");
215        if !self.files_touched.contains(&normalized) {
216            self.files_touched.push(normalized);
217        }
218    }
219
220    /// Add token usage from a tool call or response.
221    pub fn add_tokens(&mut self, tokens: &TokenUsage) {
222        let usage = self.token_usage.get_or_insert_with(TokenUsage::default);
223        usage.add(tokens);
224    }
225}
226
227// ---------------------------------------------------------------------------
228// Session state persistence (.git/entire-sessions/)
229// ---------------------------------------------------------------------------
230
231/// Get the sessions directory — uses `.git/entire-sessions/` for
232/// compatibility with entire.io. Falls back to `.git/chub-sessions/`.
233fn sessions_dir() -> Option<PathBuf> {
234    let project_root = find_project_root(None)?;
235    let git_dir = project_root.join(".git");
236    if git_dir.is_dir() {
237        // Prefer entire-sessions for compatibility
238        Some(git_dir.join("entire-sessions"))
239    } else {
240        None
241    }
242}
243
244/// Save session state to disk.
245pub fn save_state(state: &SessionState) -> bool {
246    let dir = match sessions_dir() {
247        Some(d) => d,
248        None => return false,
249    };
250    let _ = fs::create_dir_all(&dir);
251    let path = dir.join(format!("{}.json", state.session_id));
252    let json = match serde_json::to_string_pretty(state) {
253        Ok(j) => j + "\n", // entire.io terminates with newline
254        Err(_) => return false,
255    };
256    crate::util::atomic_write(&path, json.as_bytes()).is_ok()
257}
258
259/// Load a session state by ID.
260pub fn load_state(session_id: &str) -> Option<SessionState> {
261    let dir = sessions_dir()?;
262    let path = dir.join(format!("{}.json", session_id));
263    let content = fs::read_to_string(&path).ok()?;
264    serde_json::from_str(&content).ok()
265}
266
267/// Delete a session state file.
268pub fn delete_state(session_id: &str) -> bool {
269    let dir = match sessions_dir() {
270        Some(d) => d,
271        None => return false,
272    };
273    let path = dir.join(format!("{}.json", session_id));
274    fs::remove_file(&path).is_ok()
275}
276
277/// List all session states, filtering out stale ones (>7 days old).
278pub fn list_states() -> Vec<SessionState> {
279    let dir = match sessions_dir() {
280        Some(d) => d,
281        None => return vec![],
282    };
283    if !dir.is_dir() {
284        return vec![];
285    }
286
287    let now_secs = std::time::SystemTime::now()
288        .duration_since(std::time::UNIX_EPOCH)
289        .unwrap_or_default()
290        .as_secs();
291    let stale_threshold = 7 * 86400; // 7 days
292
293    let mut states = Vec::new();
294    let mut stale_ids = Vec::new();
295
296    if let Ok(entries) = fs::read_dir(&dir) {
297        for entry in entries.flatten() {
298            let path = entry.path();
299            if path.extension().map(|e| e == "json").unwrap_or(false) {
300                if let Ok(content) = fs::read_to_string(&path) {
301                    if let Ok(state) = serde_json::from_str::<SessionState>(&content) {
302                        // Check staleness
303                        let last_time = state
304                            .last_interaction_time
305                            .as_deref()
306                            .unwrap_or(&state.started_at);
307                        if is_stale(last_time, now_secs, stale_threshold) {
308                            stale_ids.push(state.session_id.clone());
309                            continue;
310                        }
311                        states.push(state);
312                    }
313                }
314            }
315        }
316    }
317
318    // Auto-clean stale sessions
319    for id in stale_ids {
320        delete_state(&id);
321    }
322
323    states.sort_by(|a, b| b.started_at.cmp(&a.started_at));
324    states
325}
326
327/// Get the currently active session (phase != Ended).
328pub fn get_active_state() -> Option<SessionState> {
329    list_states().into_iter().find(|s| s.phase != Phase::Ended)
330}
331
332// ---------------------------------------------------------------------------
333// Helpers
334// ---------------------------------------------------------------------------
335
336/// Generate a session ID in YYYY-MM-DD-<uuid> format (entire.io compatible).
337fn generate_session_id() -> String {
338    let now = now_iso8601();
339    let date = now.get(..10).unwrap_or("0000-00-00");
340    let hex = random_hex(8);
341    format!("{}-{}", date, hex)
342}
343
344fn random_hex(len: usize) -> String {
345    use std::collections::hash_map::DefaultHasher;
346    use std::hash::{Hash, Hasher};
347
348    let mut hasher = DefaultHasher::new();
349    std::time::SystemTime::now().hash(&mut hasher);
350    std::process::id().hash(&mut hasher);
351    std::thread::current().id().hash(&mut hasher);
352    let hash = hasher.finish();
353    let hex = format!("{:016x}", hash);
354    hex[..len.min(16)].to_string()
355}
356
357/// Convert agent CLI name to entire.io display name.
358/// entire.io uses: "Claude Code", "Cursor IDE", "Gemini CLI", "OpenCode", "Agent"
359fn agent_type_display(agent: &str) -> String {
360    match agent.to_lowercase().as_str() {
361        "claude-code" | "claude" | "claudecode" => "Claude Code".to_string(),
362        "cursor" => "Cursor IDE".to_string(),
363        "gemini-cli" | "gemini" => "Gemini CLI".to_string(),
364        "copilot" | "github-copilot" => "GitHub Copilot".to_string(),
365        "opencode" => "OpenCode".to_string(),
366        "aider" => "Aider".to_string(),
367        "codex" => "Codex".to_string(),
368        "windsurf" => "Windsurf".to_string(),
369        "cline" => "Cline".to_string(),
370        _ => "Agent".to_string(),
371    }
372}
373
374fn get_head_commit() -> Option<String> {
375    std::process::Command::new("git")
376        .args(["rev-parse", "HEAD"])
377        .output()
378        .ok()
379        .and_then(|o| {
380            let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
381            if s.is_empty() {
382                None
383            } else {
384                Some(s)
385            }
386        })
387}
388
389fn is_stale(iso_time: &str, now_secs: u64, threshold_secs: u64) -> bool {
390    // Simple heuristic: parse year/month/day and approximate
391    let parts: Vec<&str> = iso_time.split('T').collect();
392    if parts.is_empty() {
393        return false;
394    }
395    let date_parts: Vec<u64> = parts[0].split('-').filter_map(|p| p.parse().ok()).collect();
396    if date_parts.len() != 3 {
397        return false;
398    }
399    let (y, m, d) = (date_parts[0], date_parts[1], date_parts[2]);
400    let approx_secs = y * 365 * 86400 + m * 30 * 86400 + d * 86400;
401
402    let now_y = now_secs / (365 * 86400);
403    let now_approx = now_y * 365 * 86400
404        + ((now_secs % (365 * 86400)) / (30 * 86400)) * 30 * 86400
405        + ((now_secs % (30 * 86400)) / 86400) * 86400;
406
407    now_approx.saturating_sub(approx_secs) > threshold_secs
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn session_state_json_roundtrip() {
416        let state = SessionState::new("claude-code", Some("claude-opus-4-6"));
417        let json = serde_json::to_string_pretty(&state).unwrap();
418        let parsed: SessionState = serde_json::from_str(&json).unwrap();
419        assert_eq!(parsed.session_id, state.session_id);
420        assert_eq!(parsed.phase, Phase::Active);
421        assert!(json.contains("\"sessionID\"")); // camelCase field names
422        assert!(json.contains("\"stepCount\"")); // entire.io field name
423        assert!(json.contains("\"startedAt\""));
424        assert!(json.contains("\"agentType\""));
425    }
426
427    #[test]
428    fn phase_transitions() {
429        let mut state = SessionState::new("claude-code", None);
430        // Active → TurnEnd → Idle
431        assert!(state.apply_event(SessionEvent::TurnEnd));
432        assert_eq!(state.phase, Phase::Idle);
433        // Idle → TurnStart → Active
434        assert!(state.apply_event(SessionEvent::TurnStart));
435        assert_eq!(state.phase, Phase::Active);
436        assert_eq!(state.step_count, 1); // new() starts at 0, TurnStart increments to 1
437                                         // Active → Stop → Ended
438        assert!(state.apply_event(SessionEvent::SessionStop));
439        assert_eq!(state.phase, Phase::Ended);
440        // Ended → TurnStart → Active (re-activate)
441        assert!(state.apply_event(SessionEvent::TurnStart));
442        assert_eq!(state.phase, Phase::Active);
443    }
444
445    #[test]
446    fn touch_file_deduplicates() {
447        let mut state = SessionState::new("test", None);
448        state.touch_file("src/main.rs");
449        state.touch_file("src/main.rs");
450        state.touch_file("src\\lib.rs"); // backslash normalized
451        assert_eq!(state.files_touched.len(), 2);
452        assert!(state.files_touched.contains(&"src/lib.rs".to_string()));
453    }
454
455    // --- Phase transition coverage ---
456
457    #[test]
458    fn invalid_transitions_rejected() {
459        // IDLE → TurnEnd should be invalid
460        let mut state = SessionState::new("test", None);
461        state.phase = Phase::Idle;
462        assert!(!state.apply_event(SessionEvent::TurnEnd));
463        assert_eq!(state.phase, Phase::Idle, "phase should not change");
464
465        // IDLE → Compaction should be invalid
466        let mut state2 = SessionState::new("test", None);
467        state2.phase = Phase::Idle;
468        assert!(!state2.apply_event(SessionEvent::Compaction));
469
470        // ENDED → TurnEnd should be invalid
471        let mut state3 = SessionState::new("test", None);
472        state3.phase = Phase::Ended;
473        assert!(!state3.apply_event(SessionEvent::TurnEnd));
474
475        // ENDED → SessionStop should be invalid
476        let mut state4 = SessionState::new("test", None);
477        state4.phase = Phase::Ended;
478        assert!(!state4.apply_event(SessionEvent::SessionStop));
479
480        // ENDED → Compaction should be invalid
481        let mut state5 = SessionState::new("test", None);
482        state5.phase = Phase::Ended;
483        assert!(!state5.apply_event(SessionEvent::Compaction));
484    }
485
486    #[test]
487    fn idle_git_commit_stays_idle() {
488        let mut state = SessionState::new("test", None);
489        state.phase = Phase::Idle;
490        assert!(state.apply_event(SessionEvent::GitCommit));
491        assert_eq!(state.phase, Phase::Idle);
492    }
493
494    #[test]
495    fn active_git_commit_stays_active() {
496        let mut state = SessionState::new("test", None);
497        assert!(state.apply_event(SessionEvent::GitCommit));
498        assert_eq!(state.phase, Phase::Active);
499    }
500
501    #[test]
502    fn active_compaction_stays_active() {
503        let mut state = SessionState::new("test", None);
504        assert!(state.apply_event(SessionEvent::Compaction));
505        assert_eq!(state.phase, Phase::Active);
506    }
507
508    #[test]
509    fn ended_turn_start_reactivates() {
510        let mut state = SessionState::new("test", None);
511        state.apply_event(SessionEvent::SessionStop);
512        assert_eq!(state.phase, Phase::Ended);
513        assert!(state.ended_at.is_some());
514
515        // Re-activate
516        assert!(state.apply_event(SessionEvent::TurnStart));
517        assert_eq!(state.phase, Phase::Active);
518        assert!(state.ended_at.is_none(), "ended_at should be cleared");
519    }
520
521    #[test]
522    fn ended_git_commit_with_files_stays_ended() {
523        let mut state = SessionState::new("test", None);
524        state.files_touched.push("src/main.rs".to_string());
525        state.apply_event(SessionEvent::SessionStop);
526
527        // GitCommit on Ended with files should succeed and stay Ended
528        assert!(state.apply_event(SessionEvent::GitCommit));
529        assert_eq!(state.phase, Phase::Ended, "should stay Ended");
530        // last_interaction_time should be set (may or may not differ within same second)
531        assert!(state.last_interaction_time.is_some());
532    }
533
534    #[test]
535    fn step_count_increments_on_turn_start() {
536        let mut state = SessionState::new("test", None);
537        assert_eq!(state.step_count, 0);
538
539        // Active → TurnEnd → Idle → TurnStart → Active
540        state.apply_event(SessionEvent::TurnEnd);
541        state.apply_event(SessionEvent::TurnStart);
542        assert_eq!(state.step_count, 1);
543
544        state.apply_event(SessionEvent::TurnEnd);
545        state.apply_event(SessionEvent::TurnStart);
546        assert_eq!(state.step_count, 2);
547    }
548
549    // --- Touch file edge cases ---
550
551    #[test]
552    fn touch_file_normalizes_backslashes() {
553        let mut state = SessionState::new("test", None);
554        state.touch_file("src\\nested\\deep\\file.rs");
555        assert!(state
556            .files_touched
557            .contains(&"src/nested/deep/file.rs".to_string()));
558    }
559
560    #[test]
561    fn touch_file_empty_string() {
562        let mut state = SessionState::new("test", None);
563        state.touch_file("");
564        // Empty should still be added (it's a relative path)
565        assert_eq!(state.files_touched.len(), 1);
566    }
567
568    // --- Token addition ---
569
570    #[test]
571    fn add_tokens_creates_usage_if_none() {
572        let mut state = SessionState::new("test", None);
573        state.token_usage = None;
574        let tokens = super::TokenUsage {
575            input_tokens: 500,
576            output_tokens: 200,
577            ..Default::default()
578        };
579        state.add_tokens(&tokens);
580        assert!(state.token_usage.is_some());
581        assert_eq!(state.token_usage.as_ref().unwrap().input_tokens, 500);
582    }
583
584    #[test]
585    fn add_tokens_accumulates() {
586        let mut state = SessionState::new("test", None);
587        let t1 = super::TokenUsage {
588            input_tokens: 100,
589            ..Default::default()
590        };
591        let t2 = super::TokenUsage {
592            input_tokens: 200,
593            ..Default::default()
594        };
595        state.add_tokens(&t1);
596        state.add_tokens(&t2);
597        assert_eq!(state.token_usage.as_ref().unwrap().input_tokens, 300);
598    }
599
600    // --- Staleness ---
601
602    #[test]
603    fn is_stale_recent_date_not_stale() {
604        let now_secs = std::time::SystemTime::now()
605            .duration_since(std::time::UNIX_EPOCH)
606            .unwrap()
607            .as_secs();
608        // A date from today should not be stale
609        assert!(!is_stale("2026-03-28T12:00:00.000Z", now_secs, 7 * 86400));
610    }
611
612    #[test]
613    fn is_stale_malformed_date() {
614        assert!(!is_stale("not-a-date", 1000000, 86400));
615        assert!(!is_stale("", 1000000, 86400));
616        assert!(!is_stale("2026", 1000000, 86400));
617    }
618
619    // --- Session state JSON field names ---
620
621    #[test]
622    fn session_state_entire_io_field_names() {
623        let mut state = SessionState::new("claude-code", None);
624        // Add a file so filesTouched is not empty (empty vecs are skipped)
625        state.touch_file("src/main.rs");
626
627        let json = serde_json::to_string(&state).unwrap();
628
629        // Must use entire.io's capital-ID convention
630        assert!(
631            json.contains("\"sessionID\""),
632            "must be sessionID not sessionId"
633        );
634        assert!(json.contains("\"baseCommit\""));
635        assert!(json.contains("\"startedAt\""));
636        assert!(json.contains("\"filesTouched\""));
637        assert!(json.contains("\"stepCount\""));
638        assert!(json.contains("\"agentType\""));
639
640        // These should only appear when set
641        assert!(
642            !json.contains("\"endedAt\""),
643            "endedAt should be skipped when None"
644        );
645        assert!(
646            !json.contains("\"worktreeID\""),
647            "worktreeID should be skipped"
648        );
649        assert!(!json.contains("\"turnID\""), "turnID should be skipped");
650    }
651
652    #[test]
653    fn session_state_roundtrip_with_all_fields() {
654        let mut state = SessionState::new("cursor", Some("gpt-4o"));
655        state.touch_file("src/app.tsx");
656        state.tool_calls = 5;
657        state.tools_used.insert("Read".to_string());
658        state.tools_used.insert("Edit".to_string());
659        state.commits.push("abc1234".to_string());
660        state.est_cost_usd = Some(1.23);
661
662        let json = serde_json::to_string_pretty(&state).unwrap();
663        let parsed: SessionState = serde_json::from_str(&json).unwrap();
664
665        assert_eq!(parsed.session_id, state.session_id);
666        assert_eq!(parsed.tool_calls, 5);
667        assert!(parsed.tools_used.contains("Read"));
668        assert!(parsed.tools_used.contains("Edit"));
669        assert_eq!(parsed.commits, vec!["abc1234"]);
670        assert_eq!(parsed.est_cost_usd, Some(1.23));
671        assert_eq!(parsed.files_touched, vec!["src/app.tsx"]);
672    }
673}