Skip to main content

claude_session_parser/
chat.rs

1use chrono::DateTime;
2use serde::Serialize;
3use std::collections::HashSet;
4use std::fs;
5use std::io::{BufRead, BufReader};
6use std::path::Path;
7
8/// Flat, timestamp-ordered event stream extracted from a Claude Code session's
9/// JSONL. Produced by `parse_chat_events` and rendered by the frontend chat
10/// view. This is a read-only projection of the same file used by the usage
11/// tracker — the live PTY remains the input path.
12///
13/// Pairing of `ToolUse` → `ToolResult` is left to the frontend via `tool_use_id`
14/// so the event stream stays flat and cheap to serialize.
15#[derive(Debug, Clone, Serialize)]
16#[serde(rename_all = "camelCase", rename_all_fields = "camelCase", tag = "kind")]
17pub enum ChatEvent {
18    /// A real user turn (keyboard input). Does NOT include `tool_result`
19    /// entries — those are emitted as `ToolResult` below.
20    #[serde(rename = "user")]
21    UserText {
22        timestamp_ms: u64,
23        text: String,
24    },
25    /// Assistant text block. One `ChatEvent::AssistantText` per text block in
26    /// the assistant message; turns with no text block emit none.
27    #[serde(rename = "assistant")]
28    AssistantText {
29        timestamp_ms: u64,
30        message_id: String,
31        model: String,
32        text: String,
33    },
34    /// Extended-thinking block (Opus). Collapsed by default in the UI.
35    #[serde(rename = "thinking")]
36    Thinking {
37        timestamp_ms: u64,
38        message_id: String,
39        model: String,
40        text: String,
41    },
42    /// Tool invocation by the assistant. `input_json` is the raw JSON of the
43    /// `input` field, serialized as a string — the frontend decides whether to
44    /// pretty-print, truncate, or tree-render.
45    #[serde(rename = "tool-use")]
46    ToolUse {
47        timestamp_ms: u64,
48        message_id: String,
49        tool_use_id: String,
50        name: String,
51        input_json: String,
52    },
53    /// Tool result injected by the harness. Pairs with a `ToolUse` by
54    /// `tool_use_id`. `content` is the text payload (stringified if the JSONL
55    /// stored it as a block array).
56    #[serde(rename = "tool-result")]
57    ToolResult {
58        timestamp_ms: u64,
59        tool_use_id: String,
60        content: String,
61        is_error: bool,
62    },
63}
64
65use super::MAX_JSONL_BYTES;
66
67/// Parse a session JSONL into a flat `Vec<ChatEvent>` for chat-view rendering.
68///
69/// Mirrors `parse_jsonl_entries`'s skip rules:
70/// - Malformed lines silently skipped.
71/// - `model: "<synthetic>"` assistant messages skipped (internal routing, no
72///   real turn).
73/// - `type:"user"` with tool-result-only content is classified as `ToolResult`,
74///   not `UserText`.
75/// - Assistant messages dedup by `message.id` (Claude Code writes 2-3 streaming
76///   chunks with identical payloads).
77pub fn parse_chat_events(path: &Path) -> std::io::Result<Vec<ChatEvent>> {
78    let meta = fs::metadata(path)?;
79    if meta.len() > MAX_JSONL_BYTES {
80        tracing::warn!(
81            path = %path.display(),
82            size = meta.len(),
83            "chat: skipping JSONL larger than {}MB",
84            MAX_JSONL_BYTES / 1024 / 1024
85        );
86        return Ok(Vec::new());
87    }
88
89    let file = fs::File::open(path)?;
90    let reader = BufReader::new(file);
91
92    // Dedup key is (message_id + content-serialized). Claude Code splits a
93    // single assistant message across MULTIPLE JSONL rows — one per completed
94    // content block (thinking, then text, then tool_use, …) — all sharing the
95    // same `message.id` but carrying DIFFERENT content arrays. Deduping by
96    // message.id alone (as usage.rs does for billing) would drop every block
97    // after the first, which is why the chat view was missing assistant
98    // replies. Including the content string in the key keeps distinct blocks
99    // while still collapsing any true exact-duplicate rows if a Claude Code
100    // version emits them.
101    let mut seen_assistant: HashSet<String> = HashSet::new();
102    let mut out: Vec<ChatEvent> = Vec::new();
103
104    for line in reader.lines() {
105        let line = match line {
106            Ok(l) => l,
107            Err(_) => continue,
108        };
109        let line = line.trim();
110        if line.is_empty() {
111            continue;
112        }
113
114        let raw: serde_json::Value = match serde_json::from_str(line) {
115            Ok(v) => v,
116            Err(_) => continue,
117        };
118
119        let entry_type = raw.get("type").and_then(|v| v.as_str()).unwrap_or("");
120        let timestamp_ms = raw
121            .get("timestamp")
122            .and_then(|v| v.as_str())
123            .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
124            .map(|dt| dt.timestamp_millis().max(0) as u64)
125            .unwrap_or(0);
126
127        match entry_type {
128            "user" => {
129                let msg = match raw.get("message") {
130                    Some(m) => m,
131                    None => continue,
132                };
133                let content = match msg.get("content") {
134                    Some(c) => c,
135                    None => continue,
136                };
137                push_user_events(content, timestamp_ms, &mut out);
138            }
139            "assistant" => {
140                let msg = match raw.get("message") {
141                    Some(m) => m,
142                    None => continue,
143                };
144                let mid = match msg.get("id").and_then(|v| v.as_str()) {
145                    Some(s) => s.to_string(),
146                    None => continue,
147                };
148                let model = msg
149                    .get("model")
150                    .and_then(|v| v.as_str())
151                    .unwrap_or("")
152                    .to_string();
153                if model == "<synthetic>" {
154                    continue;
155                }
156                let content = match msg.get("content") {
157                    Some(c) => c,
158                    None => continue,
159                };
160                // Key on (message_id + serialized content) — see comment at
161                // `seen_assistant` declaration for why id alone is wrong here.
162                let content_sig = serde_json::to_string(content).unwrap_or_default();
163                let dedup_key = format!("{}|{}", mid, content_sig);
164                if !seen_assistant.insert(dedup_key) {
165                    continue;
166                }
167                push_assistant_events(content, timestamp_ms, &mid, &model, &mut out);
168            }
169            _ => {
170                // Sidecar types (file-history-snapshot, permission-mode, …)
171                // are ignored for chat rendering.
172            }
173        }
174    }
175
176    Ok(out)
177}
178
179/// A user JSONL row's `content` field may be:
180/// - A plain string → emit one `UserText`.
181/// - An array of blocks. Each block can be:
182///   - `{"type":"text","text":"..."}` → `UserText`
183///   - `{"type":"tool_result","tool_use_id":"...","content":"...","is_error":bool}` → `ToolResult`
184///     (content can itself be a string or an array of text blocks).
185fn push_user_events(content: &serde_json::Value, timestamp_ms: u64, out: &mut Vec<ChatEvent>) {
186    match content {
187        serde_json::Value::String(s) => {
188            if !s.is_empty() {
189                out.push(ChatEvent::UserText {
190                    timestamp_ms,
191                    text: s.clone(),
192                });
193            }
194        }
195        serde_json::Value::Array(arr) => {
196            for item in arr {
197                let t = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
198                match t {
199                    "text" => {
200                        if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
201                            if !text.is_empty() {
202                                out.push(ChatEvent::UserText {
203                                    timestamp_ms,
204                                    text: text.to_string(),
205                                });
206                            }
207                        }
208                    }
209                    "tool_result" => {
210                        let tool_use_id = item
211                            .get("tool_use_id")
212                            .and_then(|v| v.as_str())
213                            .unwrap_or("")
214                            .to_string();
215                        let is_error = item
216                            .get("is_error")
217                            .and_then(|v| v.as_bool())
218                            .unwrap_or(false);
219                        let content_str = stringify_result_content(item.get("content"));
220                        out.push(ChatEvent::ToolResult {
221                            timestamp_ms,
222                            tool_use_id,
223                            content: content_str,
224                            is_error,
225                        });
226                    }
227                    _ => {}
228                }
229            }
230        }
231        _ => {}
232    }
233}
234
235/// Extract assistant content blocks into a sequence of `AssistantText`,
236/// `Thinking`, and `ToolUse` events — preserving the source order inside the
237/// message so the UI renders text → tool_use → text naturally.
238fn push_assistant_events(
239    content: &serde_json::Value,
240    timestamp_ms: u64,
241    message_id: &str,
242    model: &str,
243    out: &mut Vec<ChatEvent>,
244) {
245    let arr = match content.as_array() {
246        Some(a) => a,
247        None => return,
248    };
249
250    for block in arr {
251        let block_type = match block.get("type").and_then(|v| v.as_str()) {
252            Some(t) => t,
253            None => continue,
254        };
255        match block_type {
256            "text" => {
257                if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
258                    if !text.is_empty() {
259                        out.push(ChatEvent::AssistantText {
260                            timestamp_ms,
261                            message_id: message_id.to_string(),
262                            model: model.to_string(),
263                            text: text.to_string(),
264                        });
265                    }
266                }
267            }
268            "thinking" => {
269                if let Some(text) = block.get("thinking").and_then(|v| v.as_str()) {
270                    if !text.is_empty() {
271                        out.push(ChatEvent::Thinking {
272                            timestamp_ms,
273                            message_id: message_id.to_string(),
274                            model: model.to_string(),
275                            text: text.to_string(),
276                        });
277                    }
278                }
279            }
280            "tool_use" => {
281                let name = block
282                    .get("name")
283                    .and_then(|v| v.as_str())
284                    .unwrap_or("")
285                    .to_string();
286                let tool_use_id = block
287                    .get("id")
288                    .and_then(|v| v.as_str())
289                    .unwrap_or("")
290                    .to_string();
291                let input_json = block
292                    .get("input")
293                    .map(|v| serde_json::to_string(v).unwrap_or_default())
294                    .unwrap_or_default();
295                out.push(ChatEvent::ToolUse {
296                    timestamp_ms,
297                    message_id: message_id.to_string(),
298                    tool_use_id,
299                    name,
300                    input_json,
301                });
302            }
303            _ => {}
304        }
305    }
306}
307
308/// Tool-result `content` can be a string or an array of text blocks. Collapse
309/// to a single string; the frontend truncates for display.
310fn stringify_result_content(content: Option<&serde_json::Value>) -> String {
311    let v = match content {
312        Some(v) => v,
313        None => return String::new(),
314    };
315    match v {
316        serde_json::Value::String(s) => s.clone(),
317        serde_json::Value::Array(arr) => {
318            let mut parts: Vec<String> = Vec::new();
319            for item in arr {
320                let t = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
321                if t == "text" {
322                    if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
323                        parts.push(text.to_string());
324                    }
325                }
326            }
327            parts.join("\n")
328        }
329        other => other.to_string(),
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use std::io::Write;
337    use tempfile::TempDir;
338
339    fn write_jsonl(lines: &[&str]) -> (TempDir, std::path::PathBuf) {
340        let tmp = TempDir::new().unwrap();
341        let path = tmp.path().join("session.jsonl");
342        let mut f = fs::File::create(&path).unwrap();
343        for l in lines {
344            writeln!(f, "{}", l).unwrap();
345        }
346        (tmp, path)
347    }
348
349    #[test]
350    fn parses_plain_user_and_assistant_text() {
351        let (_tmp, path) = write_jsonl(&[
352            r#"{"type":"user","timestamp":"2026-04-14T08:00:00.000Z","message":{"content":"hi claude"}}"#,
353            r#"{"type":"assistant","timestamp":"2026-04-14T08:00:01.000Z","message":{"id":"msg_01","model":"claude-opus-4-6","content":[{"type":"text","text":"hello!"}]}}"#,
354        ]);
355        let events = parse_chat_events(&path).unwrap();
356        assert_eq!(events.len(), 2);
357        match &events[0] {
358            ChatEvent::UserText { text, .. } => assert_eq!(text, "hi claude"),
359            _ => panic!("expected UserText"),
360        }
361        match &events[1] {
362            ChatEvent::AssistantText { text, model, .. } => {
363                assert_eq!(text, "hello!");
364                assert_eq!(model, "claude-opus-4-6");
365            }
366            _ => panic!("expected AssistantText"),
367        }
368    }
369
370    #[test]
371    fn dedups_exact_duplicate_assistant_rows() {
372        // Exact-duplicate rows (same id, same content) collapse to one event.
373        let line = r#"{"type":"assistant","timestamp":"2026-04-14T08:00:01.000Z","message":{"id":"msg_01","model":"claude-opus-4-6","content":[{"type":"text","text":"hello"}]}}"#;
374        let (_tmp, path) = write_jsonl(&[line, line, line]);
375        let events = parse_chat_events(&path).unwrap();
376        assert_eq!(events.len(), 1);
377    }
378
379    #[test]
380    fn keeps_distinct_blocks_sharing_message_id() {
381        // Regression test: Claude Code splits a single assistant message into
382        // separate JSONL rows per completed content block (thinking → text →
383        // tool_use). All rows share message.id but carry DIFFERENT content.
384        // Deduping by message.id alone would drop the text and tool_use rows.
385        let (_tmp, path) = write_jsonl(&[
386            r#"{"type":"assistant","timestamp":"2026-04-14T08:00:01.000Z","message":{"id":"msg_01","model":"claude-haiku-4-5","content":[{"type":"thinking","thinking":"hmm"}]}}"#,
387            r#"{"type":"assistant","timestamp":"2026-04-14T08:00:02.000Z","message":{"id":"msg_01","model":"claude-haiku-4-5","content":[{"type":"text","text":"hello world"}]}}"#,
388            r#"{"type":"assistant","timestamp":"2026-04-14T08:00:03.000Z","message":{"id":"msg_01","model":"claude-haiku-4-5","content":[{"type":"tool_use","id":"tu_1","name":"Read","input":{"file_path":"/x"}}]}}"#,
389        ]);
390        let events = parse_chat_events(&path).unwrap();
391        assert_eq!(events.len(), 3, "should keep thinking + text + tool_use distinct blocks");
392        assert!(matches!(events[0], ChatEvent::Thinking { .. }));
393        match &events[1] {
394            ChatEvent::AssistantText { text, .. } => assert_eq!(text, "hello world"),
395            _ => panic!("expected AssistantText"),
396        }
397        assert!(matches!(events[2], ChatEvent::ToolUse { .. }));
398    }
399
400    #[test]
401    fn skips_synthetic_model() {
402        let (_tmp, path) = write_jsonl(&[
403            r#"{"type":"assistant","timestamp":"2026-04-14T08:00:01.000Z","message":{"id":"m1","model":"<synthetic>","content":[{"type":"text","text":"internal"}]}}"#,
404            r#"{"type":"assistant","timestamp":"2026-04-14T08:00:02.000Z","message":{"id":"m2","model":"claude-sonnet-4-6","content":[{"type":"text","text":"real"}]}}"#,
405        ]);
406        let events = parse_chat_events(&path).unwrap();
407        assert_eq!(events.len(), 1);
408        match &events[0] {
409            ChatEvent::AssistantText { text, .. } => assert_eq!(text, "real"),
410            _ => panic!("expected AssistantText"),
411        }
412    }
413
414    #[test]
415    fn emits_tool_use_and_pairs_with_tool_result() {
416        let (_tmp, path) = write_jsonl(&[
417            r#"{"type":"assistant","timestamp":"2026-04-14T08:00:01.000Z","message":{"id":"m1","model":"claude-sonnet-4-6","content":[{"type":"tool_use","id":"tu_1","name":"Read","input":{"file_path":"/tmp/x"}}]}}"#,
418            r#"{"type":"user","timestamp":"2026-04-14T08:00:02.000Z","message":{"content":[{"type":"tool_result","tool_use_id":"tu_1","content":"contents here","is_error":false}]}}"#,
419        ]);
420        let events = parse_chat_events(&path).unwrap();
421        assert_eq!(events.len(), 2);
422        match &events[0] {
423            ChatEvent::ToolUse { name, tool_use_id, input_json, .. } => {
424                assert_eq!(name, "Read");
425                assert_eq!(tool_use_id, "tu_1");
426                assert!(input_json.contains("file_path"));
427            }
428            _ => panic!("expected ToolUse"),
429        }
430        match &events[1] {
431            ChatEvent::ToolResult { tool_use_id, content, is_error, .. } => {
432                assert_eq!(tool_use_id, "tu_1");
433                assert_eq!(content, "contents here");
434                assert!(!is_error);
435            }
436            _ => panic!("expected ToolResult"),
437        }
438    }
439
440    #[test]
441    fn tool_result_handles_array_content() {
442        let (_tmp, path) = write_jsonl(&[
443            r#"{"type":"user","timestamp":"2026-04-14T08:00:02.000Z","message":{"content":[{"type":"tool_result","tool_use_id":"tu_1","content":[{"type":"text","text":"line1"},{"type":"text","text":"line2"}],"is_error":false}]}}"#,
444        ]);
445        let events = parse_chat_events(&path).unwrap();
446        assert_eq!(events.len(), 1);
447        match &events[0] {
448            ChatEvent::ToolResult { content, .. } => {
449                assert_eq!(content, "line1\nline2");
450            }
451            _ => panic!("expected ToolResult"),
452        }
453    }
454
455    #[test]
456    fn tool_result_is_error_propagates() {
457        let (_tmp, path) = write_jsonl(&[
458            r#"{"type":"user","timestamp":"2026-04-14T08:00:02.000Z","message":{"content":[{"type":"tool_result","tool_use_id":"tu_1","content":"boom","is_error":true}]}}"#,
459        ]);
460        let events = parse_chat_events(&path).unwrap();
461        match &events[0] {
462            ChatEvent::ToolResult { is_error, .. } => assert!(*is_error),
463            _ => panic!("expected ToolResult"),
464        }
465    }
466
467    #[test]
468    fn user_array_with_text_emits_user_text() {
469        let (_tmp, path) = write_jsonl(&[
470            r#"{"type":"user","timestamp":"2026-04-14T08:00:00.000Z","message":{"content":[{"type":"text","text":"typed message"}]}}"#,
471        ]);
472        let events = parse_chat_events(&path).unwrap();
473        assert_eq!(events.len(), 1);
474        match &events[0] {
475            ChatEvent::UserText { text, .. } => assert_eq!(text, "typed message"),
476            _ => panic!("expected UserText"),
477        }
478    }
479
480    #[test]
481    fn thinking_block_emitted() {
482        let (_tmp, path) = write_jsonl(&[
483            r#"{"type":"assistant","timestamp":"2026-04-14T08:00:01.000Z","message":{"id":"m1","model":"claude-opus-4-6","content":[{"type":"thinking","thinking":"reasoning..."},{"type":"text","text":"answer"}]}}"#,
484        ]);
485        let events = parse_chat_events(&path).unwrap();
486        assert_eq!(events.len(), 2);
487        match &events[0] {
488            ChatEvent::Thinking { text, .. } => assert_eq!(text, "reasoning..."),
489            _ => panic!("expected Thinking"),
490        }
491        match &events[1] {
492            ChatEvent::AssistantText { text, .. } => assert_eq!(text, "answer"),
493            _ => panic!("expected AssistantText"),
494        }
495    }
496
497    #[test]
498    fn skips_malformed_lines() {
499        let (_tmp, path) = write_jsonl(&[
500            "not json at all",
501            "",
502            r#"{"type":"assistant","timestamp":"2026-04-14T08:00:01.000Z","message":{"id":"m1","model":"claude-sonnet-4-6","content":[{"type":"text","text":"hi"}]}}"#,
503        ]);
504        let events = parse_chat_events(&path).unwrap();
505        assert_eq!(events.len(), 1);
506    }
507
508    #[test]
509    fn skips_sidecar_types() {
510        let (_tmp, path) = write_jsonl(&[
511            r#"{"type":"file-history-snapshot","timestamp":"2026-04-14T08:00:00.000Z"}"#,
512            r#"{"type":"permission-mode","timestamp":"2026-04-14T08:00:00.000Z"}"#,
513            r#"{"type":"assistant","timestamp":"2026-04-14T08:00:01.000Z","message":{"id":"m1","model":"claude-sonnet-4-6","content":[{"type":"text","text":"hi"}]}}"#,
514        ]);
515        let events = parse_chat_events(&path).unwrap();
516        assert_eq!(events.len(), 1);
517    }
518
519    #[test]
520    fn serializes_to_camel_case_with_kind_tag() {
521        let evt = ChatEvent::ToolUse {
522            timestamp_ms: 123,
523            message_id: "m1".into(),
524            tool_use_id: "tu_1".into(),
525            name: "Read".into(),
526            input_json: "{}".into(),
527        };
528        let s = serde_json::to_string(&evt).unwrap();
529        assert!(s.contains("\"kind\":\"tool-use\""));
530        assert!(s.contains("\"toolUseId\":\"tu_1\""));
531        assert!(s.contains("\"messageId\":\"m1\""));
532        assert!(s.contains("\"timestampMs\":123"));
533    }
534}