1use chrono::DateTime;
2use serde::Serialize;
3use std::collections::HashSet;
4use std::fs;
5use std::io::{BufRead, BufReader};
6use std::path::Path;
7
8#[derive(Debug, Clone, Serialize)]
16#[serde(rename_all = "camelCase", rename_all_fields = "camelCase", tag = "kind")]
17pub enum ChatEvent {
18 #[serde(rename = "user")]
21 UserText {
22 timestamp_ms: u64,
23 text: String,
24 },
25 #[serde(rename = "assistant")]
28 AssistantText {
29 timestamp_ms: u64,
30 message_id: String,
31 model: String,
32 text: String,
33 },
34 #[serde(rename = "thinking")]
36 Thinking {
37 timestamp_ms: u64,
38 message_id: String,
39 model: String,
40 text: String,
41 },
42 #[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 #[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
67pub 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 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 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 }
173 }
174 }
175
176 Ok(out)
177}
178
179fn 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
233fn 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
306fn 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 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 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}