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) => {
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
235fn 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
308fn 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 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 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}