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) if !s.is_empty() => {
188            out.push(ChatEvent::UserText {
189                timestamp_ms,
190                text: s.clone(),
191            });
192        }
193        serde_json::Value::Array(arr) => {
194            for item in arr {
195                let t = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
196                match t {
197                    "text" => {
198                        if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
199                            if !text.is_empty() {
200                                out.push(ChatEvent::UserText {
201                                    timestamp_ms,
202                                    text: text.to_string(),
203                                });
204                            }
205                        }
206                    }
207                    "tool_result" => {
208                        let tool_use_id = item
209                            .get("tool_use_id")
210                            .and_then(|v| v.as_str())
211                            .unwrap_or("")
212                            .to_string();
213                        let is_error = item
214                            .get("is_error")
215                            .and_then(|v| v.as_bool())
216                            .unwrap_or(false);
217                        let content_str = stringify_result_content(item.get("content"));
218                        out.push(ChatEvent::ToolResult {
219                            timestamp_ms,
220                            tool_use_id,
221                            content: content_str,
222                            is_error,
223                        });
224                    }
225                    _ => {}
226                }
227            }
228        }
229        _ => {}
230    }
231}
232
233/// Extract assistant content blocks into a sequence of `AssistantText`,
234/// `Thinking`, and `ToolUse` events — preserving the source order inside the
235/// message so the UI renders text → tool_use → text naturally.
236fn push_assistant_events(
237    content: &serde_json::Value,
238    timestamp_ms: u64,
239    message_id: &str,
240    model: &str,
241    out: &mut Vec<ChatEvent>,
242) {
243    let arr = match content.as_array() {
244        Some(a) => a,
245        None => return,
246    };
247
248    for block in arr {
249        let block_type = match block.get("type").and_then(|v| v.as_str()) {
250            Some(t) => t,
251            None => continue,
252        };
253        match block_type {
254            "text" => {
255                if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
256                    if !text.is_empty() {
257                        out.push(ChatEvent::AssistantText {
258                            timestamp_ms,
259                            message_id: message_id.to_string(),
260                            model: model.to_string(),
261                            text: text.to_string(),
262                        });
263                    }
264                }
265            }
266            "thinking" => {
267                if let Some(text) = block.get("thinking").and_then(|v| v.as_str()) {
268                    if !text.is_empty() {
269                        out.push(ChatEvent::Thinking {
270                            timestamp_ms,
271                            message_id: message_id.to_string(),
272                            model: model.to_string(),
273                            text: text.to_string(),
274                        });
275                    }
276                }
277            }
278            "tool_use" => {
279                let name = block
280                    .get("name")
281                    .and_then(|v| v.as_str())
282                    .unwrap_or("")
283                    .to_string();
284                let tool_use_id = block
285                    .get("id")
286                    .and_then(|v| v.as_str())
287                    .unwrap_or("")
288                    .to_string();
289                let input_json = block
290                    .get("input")
291                    .map(|v| serde_json::to_string(v).unwrap_or_default())
292                    .unwrap_or_default();
293                out.push(ChatEvent::ToolUse {
294                    timestamp_ms,
295                    message_id: message_id.to_string(),
296                    tool_use_id,
297                    name,
298                    input_json,
299                });
300            }
301            _ => {}
302        }
303    }
304}
305
306/// Tool-result `content` can be a string or an array of text blocks. Collapse
307/// to a single string; the frontend truncates for display.
308fn stringify_result_content(content: Option<&serde_json::Value>) -> String {
309    let v = match content {
310        Some(v) => v,
311        None => return String::new(),
312    };
313    match v {
314        serde_json::Value::String(s) => s.clone(),
315        serde_json::Value::Array(arr) => {
316            let mut parts: Vec<String> = Vec::new();
317            for item in arr {
318                let t = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
319                if t == "text" {
320                    if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
321                        parts.push(text.to_string());
322                    }
323                }
324            }
325            parts.join("\n")
326        }
327        other => other.to_string(),
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use std::io::Write;
335    use tempfile::TempDir;
336
337    fn write_jsonl(lines: &[&str]) -> (TempDir, std::path::PathBuf) {
338        let tmp = TempDir::new().unwrap();
339        let path = tmp.path().join("session.jsonl");
340        let mut f = fs::File::create(&path).unwrap();
341        for l in lines {
342            writeln!(f, "{}", l).unwrap();
343        }
344        (tmp, path)
345    }
346
347    #[test]
348    fn parses_plain_user_and_assistant_text() {
349        let (_tmp, path) = write_jsonl(&[
350            r#"{"type":"user","timestamp":"2026-04-14T08:00:00.000Z","message":{"content":"hi claude"}}"#,
351            r#"{"type":"assistant","timestamp":"2026-04-14T08:00:01.000Z","message":{"id":"msg_01","model":"claude-opus-4-6","content":[{"type":"text","text":"hello!"}]}}"#,
352        ]);
353        let events = parse_chat_events(&path).unwrap();
354        assert_eq!(events.len(), 2);
355        match &events[0] {
356            ChatEvent::UserText { text, .. } => assert_eq!(text, "hi claude"),
357            _ => panic!("expected UserText"),
358        }
359        match &events[1] {
360            ChatEvent::AssistantText { text, model, .. } => {
361                assert_eq!(text, "hello!");
362                assert_eq!(model, "claude-opus-4-6");
363            }
364            _ => panic!("expected AssistantText"),
365        }
366    }
367
368    #[test]
369    fn dedups_exact_duplicate_assistant_rows() {
370        // Exact-duplicate rows (same id, same content) collapse to one event.
371        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"}]}}"#;
372        let (_tmp, path) = write_jsonl(&[line, line, line]);
373        let events = parse_chat_events(&path).unwrap();
374        assert_eq!(events.len(), 1);
375    }
376
377    #[test]
378    fn keeps_distinct_blocks_sharing_message_id() {
379        // Regression test: Claude Code splits a single assistant message into
380        // separate JSONL rows per completed content block (thinking → text →
381        // tool_use). All rows share message.id but carry DIFFERENT content.
382        // Deduping by message.id alone would drop the text and tool_use rows.
383        let (_tmp, path) = write_jsonl(&[
384            r#"{"type":"assistant","timestamp":"2026-04-14T08:00:01.000Z","message":{"id":"msg_01","model":"claude-haiku-4-5","content":[{"type":"thinking","thinking":"hmm"}]}}"#,
385            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"}]}}"#,
386            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"}}]}}"#,
387        ]);
388        let events = parse_chat_events(&path).unwrap();
389        assert_eq!(events.len(), 3, "should keep thinking + text + tool_use distinct blocks");
390        assert!(matches!(events[0], ChatEvent::Thinking { .. }));
391        match &events[1] {
392            ChatEvent::AssistantText { text, .. } => assert_eq!(text, "hello world"),
393            _ => panic!("expected AssistantText"),
394        }
395        assert!(matches!(events[2], ChatEvent::ToolUse { .. }));
396    }
397
398    #[test]
399    fn skips_synthetic_model() {
400        let (_tmp, path) = write_jsonl(&[
401            r#"{"type":"assistant","timestamp":"2026-04-14T08:00:01.000Z","message":{"id":"m1","model":"<synthetic>","content":[{"type":"text","text":"internal"}]}}"#,
402            r#"{"type":"assistant","timestamp":"2026-04-14T08:00:02.000Z","message":{"id":"m2","model":"claude-sonnet-4-6","content":[{"type":"text","text":"real"}]}}"#,
403        ]);
404        let events = parse_chat_events(&path).unwrap();
405        assert_eq!(events.len(), 1);
406        match &events[0] {
407            ChatEvent::AssistantText { text, .. } => assert_eq!(text, "real"),
408            _ => panic!("expected AssistantText"),
409        }
410    }
411
412    #[test]
413    fn emits_tool_use_and_pairs_with_tool_result() {
414        let (_tmp, path) = write_jsonl(&[
415            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"}}]}}"#,
416            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}]}}"#,
417        ]);
418        let events = parse_chat_events(&path).unwrap();
419        assert_eq!(events.len(), 2);
420        match &events[0] {
421            ChatEvent::ToolUse { name, tool_use_id, input_json, .. } => {
422                assert_eq!(name, "Read");
423                assert_eq!(tool_use_id, "tu_1");
424                assert!(input_json.contains("file_path"));
425            }
426            _ => panic!("expected ToolUse"),
427        }
428        match &events[1] {
429            ChatEvent::ToolResult { tool_use_id, content, is_error, .. } => {
430                assert_eq!(tool_use_id, "tu_1");
431                assert_eq!(content, "contents here");
432                assert!(!is_error);
433            }
434            _ => panic!("expected ToolResult"),
435        }
436    }
437
438    #[test]
439    fn tool_result_handles_array_content() {
440        let (_tmp, path) = write_jsonl(&[
441            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}]}}"#,
442        ]);
443        let events = parse_chat_events(&path).unwrap();
444        assert_eq!(events.len(), 1);
445        match &events[0] {
446            ChatEvent::ToolResult { content, .. } => {
447                assert_eq!(content, "line1\nline2");
448            }
449            _ => panic!("expected ToolResult"),
450        }
451    }
452
453    #[test]
454    fn tool_result_is_error_propagates() {
455        let (_tmp, path) = write_jsonl(&[
456            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}]}}"#,
457        ]);
458        let events = parse_chat_events(&path).unwrap();
459        match &events[0] {
460            ChatEvent::ToolResult { is_error, .. } => assert!(*is_error),
461            _ => panic!("expected ToolResult"),
462        }
463    }
464
465    #[test]
466    fn user_array_with_text_emits_user_text() {
467        let (_tmp, path) = write_jsonl(&[
468            r#"{"type":"user","timestamp":"2026-04-14T08:00:00.000Z","message":{"content":[{"type":"text","text":"typed message"}]}}"#,
469        ]);
470        let events = parse_chat_events(&path).unwrap();
471        assert_eq!(events.len(), 1);
472        match &events[0] {
473            ChatEvent::UserText { text, .. } => assert_eq!(text, "typed message"),
474            _ => panic!("expected UserText"),
475        }
476    }
477
478    #[test]
479    fn thinking_block_emitted() {
480        let (_tmp, path) = write_jsonl(&[
481            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"}]}}"#,
482        ]);
483        let events = parse_chat_events(&path).unwrap();
484        assert_eq!(events.len(), 2);
485        match &events[0] {
486            ChatEvent::Thinking { text, .. } => assert_eq!(text, "reasoning..."),
487            _ => panic!("expected Thinking"),
488        }
489        match &events[1] {
490            ChatEvent::AssistantText { text, .. } => assert_eq!(text, "answer"),
491            _ => panic!("expected AssistantText"),
492        }
493    }
494
495    #[test]
496    fn skips_malformed_lines() {
497        let (_tmp, path) = write_jsonl(&[
498            "not json at all",
499            "",
500            r#"{"type":"assistant","timestamp":"2026-04-14T08:00:01.000Z","message":{"id":"m1","model":"claude-sonnet-4-6","content":[{"type":"text","text":"hi"}]}}"#,
501        ]);
502        let events = parse_chat_events(&path).unwrap();
503        assert_eq!(events.len(), 1);
504    }
505
506    #[test]
507    fn skips_sidecar_types() {
508        let (_tmp, path) = write_jsonl(&[
509            r#"{"type":"file-history-snapshot","timestamp":"2026-04-14T08:00:00.000Z"}"#,
510            r#"{"type":"permission-mode","timestamp":"2026-04-14T08:00:00.000Z"}"#,
511            r#"{"type":"assistant","timestamp":"2026-04-14T08:00:01.000Z","message":{"id":"m1","model":"claude-sonnet-4-6","content":[{"type":"text","text":"hi"}]}}"#,
512        ]);
513        let events = parse_chat_events(&path).unwrap();
514        assert_eq!(events.len(), 1);
515    }
516
517    #[test]
518    fn serializes_to_camel_case_with_kind_tag() {
519        let evt = ChatEvent::ToolUse {
520            timestamp_ms: 123,
521            message_id: "m1".into(),
522            tool_use_id: "tu_1".into(),
523            name: "Read".into(),
524            input_json: "{}".into(),
525        };
526        let s = serde_json::to_string(&evt).unwrap();
527        assert!(s.contains("\"kind\":\"tool-use\""));
528        assert!(s.contains("\"toolUseId\":\"tu_1\""));
529        assert!(s.contains("\"messageId\":\"m1\""));
530        assert!(s.contains("\"timestampMs\":123"));
531    }
532}