Skip to main content

agx_core/
session.rs

1use anyhow::{Context, Result};
2use serde::Deserialize;
3use std::path::Path;
4
5#[derive(Debug, Clone, Deserialize)]
6#[serde(tag = "type", rename_all = "kebab-case")]
7pub enum Entry {
8    User(UserEntry),
9    Assistant(AssistantEntry),
10    #[serde(other)]
11    Other,
12}
13
14#[derive(Debug, Clone, Deserialize)]
15pub struct UserEntry {
16    // Parsed but only read by tests + reserved for future tree-walking
17    // (parent_uuid). timestamp + message are actively read from
18    // timeline::build().
19    #[allow(dead_code)]
20    pub uuid: String,
21    #[serde(rename = "parentUuid")]
22    #[allow(dead_code)]
23    pub parent_uuid: Option<String>,
24    pub timestamp: Option<String>,
25    pub message: UserMessage,
26}
27
28#[derive(Debug, Clone, Deserialize)]
29pub struct UserMessage {
30    /// Reserved for future role-aware rendering; serde parses it but
31    /// no reader currently exists.
32    #[allow(dead_code)]
33    pub role: String,
34    pub content: UserContent,
35}
36
37#[derive(Debug, Clone, Deserialize)]
38#[serde(untagged)]
39pub enum UserContent {
40    Text(String),
41    Items(Vec<UserContentItem>),
42}
43
44#[derive(Debug, Clone, Deserialize)]
45#[serde(tag = "type", rename_all = "snake_case")]
46pub enum UserContentItem {
47    Text {
48        text: String,
49    },
50    ToolResult {
51        tool_use_id: String,
52        content: ToolResultContent,
53    },
54    #[serde(other)]
55    Other,
56}
57
58#[derive(Debug, Clone, Deserialize)]
59#[serde(untagged)]
60pub enum ToolResultContent {
61    Text(String),
62    Items(Vec<serde_json::Value>),
63}
64
65#[derive(Debug, Clone, Deserialize)]
66pub struct AssistantEntry {
67    #[allow(dead_code)]
68    pub uuid: String,
69    #[serde(rename = "parentUuid")]
70    #[allow(dead_code)]
71    pub parent_uuid: Option<String>,
72    pub timestamp: Option<String>,
73    pub message: AssistantMessage,
74}
75
76#[derive(Debug, Clone, Deserialize)]
77pub struct AssistantMessage {
78    #[allow(dead_code)]
79    pub role: String,
80    pub content: Vec<AssistantContentItem>,
81    /// Model name (e.g. "claude-opus-4-6"). Optional — older sessions may not
82    /// include it at the message level.
83    #[serde(default)]
84    pub model: Option<String>,
85    /// Usage counters for this assistant response. Applies to the whole
86    /// message, not per-content-item.
87    #[serde(default)]
88    pub usage: Option<ClaudeUsage>,
89}
90
91/// Claude Code's usage shape, mirrored from Anthropic API responses.
92#[derive(Debug, Clone, Deserialize)]
93pub struct ClaudeUsage {
94    #[serde(default)]
95    pub input_tokens: Option<u64>,
96    #[serde(default)]
97    pub output_tokens: Option<u64>,
98    #[serde(default)]
99    pub cache_creation_input_tokens: Option<u64>,
100    #[serde(default)]
101    pub cache_read_input_tokens: Option<u64>,
102}
103
104#[derive(Debug, Clone, Deserialize)]
105#[serde(tag = "type", rename_all = "snake_case")]
106pub enum AssistantContentItem {
107    Text {
108        text: String,
109    },
110    ToolUse {
111        id: String,
112        name: String,
113        input: serde_json::Value,
114    },
115    #[serde(other)]
116    Other,
117}
118
119pub fn load(path: &Path) -> Result<Vec<Entry>> {
120    // Line-stream via BufReader so we never hold the whole file in memory
121    // as a single String — a Claude Code session can be 50MB+ of JSONL,
122    // and the old `read_to_string` + `.lines()` path materialized the
123    // entire buffer just to iterate over it. BufReader keeps the working
124    // set bounded by the longest single line (typically a few KB) while
125    // still giving us accurate line-number context for format-drift
126    // error messages.
127    use std::fs::File;
128    use std::io::{BufRead, BufReader};
129    let file =
130        File::open(path).with_context(|| format!("opening session file: {}", path.display()))?;
131    let reader = BufReader::new(file);
132    let mut entries = Vec::with_capacity(1024);
133    for (i, line) in reader.lines().enumerate() {
134        let line = line.with_context(|| format!("reading line {} of session file", i + 1))?;
135        if line.trim().is_empty() {
136            continue;
137        }
138        let entry: Entry = serde_json::from_str(&line)
139            .with_context(|| format!("parsing line {} of session file", i + 1))?;
140        entries.push(entry);
141    }
142    Ok(entries)
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn parses_text_user_message() {
151        let line = r#"{"type":"user","uuid":"u1","parentUuid":null,"timestamp":"2026-04-11T00:00:00Z","message":{"role":"user","content":"hello"}}"#;
152        let entry: Entry = serde_json::from_str(line).unwrap();
153        let Entry::User(u) = entry else {
154            panic!("expected user");
155        };
156        assert!(matches!(u.message.content, UserContent::Text(ref s) if s == "hello"));
157    }
158
159    #[test]
160    fn parses_tool_result_user_message() {
161        let line = r#"{"type":"user","uuid":"u2","parentUuid":"u1","timestamp":"2026-04-11T00:00:01Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"output"}]}}"#;
162        let entry: Entry = serde_json::from_str(line).unwrap();
163        let Entry::User(u) = entry else {
164            panic!("expected user");
165        };
166        let UserContent::Items(items) = u.message.content else {
167            panic!("expected items");
168        };
169        assert_eq!(items.len(), 1);
170        assert!(
171            matches!(&items[0], UserContentItem::ToolResult { tool_use_id, .. } if tool_use_id == "t1")
172        );
173    }
174
175    #[test]
176    fn parses_assistant_with_tool_use() {
177        let line = r#"{"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"2026-04-11T00:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"thinking..."},{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"/tmp/x"}}]}}"#;
178        let entry: Entry = serde_json::from_str(line).unwrap();
179        let Entry::Assistant(a) = entry else {
180            panic!("expected assistant");
181        };
182        assert_eq!(a.message.content.len(), 2);
183        assert!(
184            matches!(&a.message.content[1], AssistantContentItem::ToolUse { name, .. } if name == "Read")
185        );
186    }
187
188    #[test]
189    fn unknown_top_level_type_becomes_other() {
190        let line = r#"{"type":"permission-mode","permissionMode":"default","sessionId":"s1"}"#;
191        let entry: Entry = serde_json::from_str(line).unwrap();
192        assert!(matches!(entry, Entry::Other));
193    }
194
195    #[test]
196    fn parses_usage_and_model_on_assistant_message() {
197        let line = r#"{"type":"assistant","uuid":"a1","parentUuid":null,"timestamp":null,"message":{"role":"assistant","model":"claude-opus-4-6","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":10,"cache_read_input_tokens":200},"content":[{"type":"text","text":"hi"}]}}"#;
198        let entry: Entry = serde_json::from_str(line).unwrap();
199        let Entry::Assistant(a) = entry else {
200            panic!("expected assistant");
201        };
202        assert_eq!(a.message.model.as_deref(), Some("claude-opus-4-6"));
203        let u = a.message.usage.as_ref().unwrap();
204        assert_eq!(u.input_tokens, Some(100));
205        assert_eq!(u.output_tokens, Some(50));
206        assert_eq!(u.cache_creation_input_tokens, Some(10));
207        assert_eq!(u.cache_read_input_tokens, Some(200));
208    }
209
210    #[test]
211    fn assistant_message_without_usage_parses_cleanly() {
212        let line = r#"{"type":"assistant","uuid":"a1","parentUuid":null,"timestamp":null,"message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}"#;
213        let entry: Entry = serde_json::from_str(line).unwrap();
214        let Entry::Assistant(a) = entry else {
215            panic!("expected assistant");
216        };
217        assert!(a.message.usage.is_none());
218        assert!(a.message.model.is_none());
219    }
220
221    #[test]
222    fn unknown_assistant_content_item_becomes_other() {
223        let line = r#"{"type":"assistant","uuid":"a2","parentUuid":null,"timestamp":null,"message":{"role":"assistant","content":[{"type":"thinking","content":"hmm"}]}}"#;
224        let entry: Entry = serde_json::from_str(line).unwrap();
225        let Entry::Assistant(a) = entry else {
226            panic!("expected assistant");
227        };
228        assert_eq!(a.message.content.len(), 1);
229        assert!(matches!(&a.message.content[0], AssistantContentItem::Other));
230    }
231}