1use std::collections::HashMap;
21use std::io::{BufRead, BufReader};
22
23use chrono::{DateTime, Utc};
24use serde_json::Value;
25
26use crate::{
27 error::VasariError,
28 ingest::{IngestAdapter, IngestEvent, IngestSource},
29};
30
31pub struct ClaudeCodeAdapter;
32
33impl IngestAdapter for ClaudeCodeAdapter {
34 fn parse(&self, source: IngestSource) -> Result<Vec<IngestEvent>, VasariError> {
35 let reader: Box<dyn BufRead> = match source {
36 IngestSource::File(path) => {
37 let file = std::fs::File::open(&path).map_err(VasariError::Io)?;
38 Box::new(BufReader::new(file))
39 }
40 IngestSource::Stdin => Box::new(BufReader::new(std::io::stdin())),
41 };
42
43 parse_jsonl(reader)
44 }
45}
46
47fn parse_jsonl(reader: impl BufRead) -> Result<Vec<IngestEvent>, VasariError> {
48 let mut events: Vec<IngestEvent> = Vec::new();
49 let mut session_start: Option<DateTime<Utc>> = None;
50 let mut session_source: Option<String> = None;
51 let mut found_intent = false;
52
53 let mut records: Vec<Value> = Vec::new();
57 for (idx, line) in reader.lines().enumerate() {
58 let line_no = idx as u64 + 1;
59 let line = line.map_err(VasariError::Io)?;
60 let line = line.trim();
61 if line.is_empty() {
62 continue;
63 }
64 match serde_json::from_str::<Value>(line) {
65 Ok(v) => records.push(v),
66 Err(e) => events.push(IngestEvent::SystemInstruction {
68 text: format!("[parse error on line {line_no}: {e}]"),
69 }),
70 }
71 }
72
73 let mut results: HashMap<String, String> = HashMap::new();
76 for record in &records {
77 collect_tool_results(record, &mut results);
78 }
79
80 let mut rationale: Option<String> = None;
86
87 for record in &records {
89 let timestamp = record
91 .get("timestamp")
92 .and_then(|v| v.as_str())
93 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
94 .map(|dt| dt.with_timezone(&Utc))
95 .unwrap_or_else(Utc::now);
96
97 if session_start.map(|st| timestamp < st).unwrap_or(true) {
99 session_start = Some(timestamp);
100 }
101
102 let record_type = record.get("type").and_then(|v| v.as_str()).unwrap_or("");
103
104 match record_type {
105 "system" => {
106 if let Some(text) = extract_text_from_record(record) {
108 events.push(IngestEvent::SystemInstruction { text });
109 }
110 }
111 "summary" => {
112 if let Some(text) = record.get("summary").and_then(|v| v.as_str()) {
115 events.push(IngestEvent::SystemInstruction {
116 text: text.to_string(),
117 });
118 }
119 }
120 "human" | "user" => {
121 let message = match record.get("message") {
123 Some(m) => m,
124 None => continue,
125 };
126
127 let content = message.get("content");
128
129 let text = extract_user_text(content);
131
132 if let Some(text) = text {
133 if text.starts_with('/') {
135 continue;
136 }
137 if is_boilerplate(&text) {
139 continue;
140 }
141 if is_option_answer(&text) {
146 continue;
147 }
148 if is_synthetic_user_text(&text) {
152 continue;
153 }
154
155 let ts = record
156 .get("timestamp")
157 .and_then(|v| v.as_str())
158 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
159 .map(|dt| dt.with_timezone(&Utc))
160 .unwrap_or(timestamp);
161
162 if !found_intent {
163 let source_label = record
165 .get("uuid")
166 .and_then(|v| v.as_str())
167 .map(|s| format!("claude-code:{s}"))
168 .unwrap_or_else(|| "claude-code:unknown".to_string());
169 session_source = Some(source_label);
170 found_intent = true;
171 }
172
173 rationale = None;
175
176 events.push(IngestEvent::UserPrompt {
177 text,
178 timestamp: ts,
179 });
180 }
181 }
182 "assistant" => {
183 let message = match record.get("message") {
185 Some(m) => m,
186 None => continue,
187 };
188
189 let cwd = record.get("cwd").and_then(|v| v.as_str());
192
193 let content = message.get("content");
194 if let Some(content_arr) = content.and_then(|c| c.as_array()) {
195 for block in content_arr {
200 match block.get("type").and_then(|v| v.as_str()) {
201 Some("text") | Some("thinking") => {
202 let field = if block.get("text").is_some() {
203 "text"
204 } else {
205 "thinking"
206 };
207 if let Some(t) = block.get(field).and_then(|v| v.as_str()) {
208 let t = t.trim();
209 if !t.is_empty() {
210 rationale = Some(t.to_string());
211 }
212 }
213 }
214 Some("tool_use") => {
215 if let Some(ev) = parse_tool_use_block(
216 block,
217 timestamp,
218 &results,
219 cwd,
220 rationale.as_deref(),
221 ) {
222 events.push(ev);
223 }
224 }
225 _ => {}
226 }
227 }
228 }
229 }
230 _ => {
231 }
233 }
234 }
235
236 let source = session_source.unwrap_or_else(|| "claude-code:unknown".to_string());
238 let started_at = session_start.unwrap_or_else(Utc::now);
239 events.insert(0, IngestEvent::SessionStart { source, started_at });
240
241 Ok(events)
242}
243
244fn extract_user_text(content: Option<&Value>) -> Option<String> {
247 let content = content?;
248 if let Some(s) = content.as_str() {
249 let t = s.trim().to_string();
250 return if t.is_empty() { None } else { Some(t) };
251 }
252 if let Some(arr) = content.as_array() {
253 let mut parts = Vec::new();
255 for block in arr {
256 let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
257 match block_type {
258 "text" => {
259 if let Some(t) = block.get("text").and_then(|v| v.as_str()) {
260 let t = t.trim();
261 if !t.is_empty() {
262 parts.push(t.to_string());
263 }
264 }
265 }
266 "tool_result" => {
267 }
269 _ => {}
270 }
271 }
272 if parts.is_empty() {
273 return None;
274 }
275 return Some(parts.join("\n"));
276 }
277 None
278}
279
280fn extract_text_from_record(record: &Value) -> Option<String> {
281 let message = record.get("message")?;
282 extract_user_text(message.get("content"))
283}
284
285fn relativize(path: &str, cwd: Option<&str>) -> String {
288 if let Some(cwd) = cwd {
289 let prefix = format!("{}/", cwd.trim_end_matches('/'));
290 if let Some(rest) = path.strip_prefix(&prefix) {
291 return rest.to_string();
292 }
293 }
294 path.to_string()
295}
296
297fn parse_tool_use_block(
298 block: &Value,
299 timestamp: DateTime<Utc>,
300 results: &HashMap<String, String>,
301 cwd: Option<&str>,
302 rationale: Option<&str>,
303) -> Option<IngestEvent> {
304 let name = block.get("name").and_then(|v| v.as_str())?.to_string();
305
306 match name.as_str() {
308 "Edit" | "Write" | "MultiEdit" | "Bash" | "Read" | "Glob" | "Grep" => {}
309 _ => return None,
310 }
311
312 let mut args = block
313 .get("input")
314 .cloned()
315 .unwrap_or(Value::Object(Default::default()));
316
317 if let Some(path) = args.get("file_path").and_then(|v| v.as_str()) {
320 let rel = relativize(path, cwd);
321 if !crate::ingest::is_safe_path(&rel) {
324 return None;
325 }
326 args["file_path"] = Value::String(rel);
327 }
328
329 let result_summary = block
333 .get("id")
334 .and_then(|v| v.as_str())
335 .and_then(|id| results.get(id))
336 .cloned()
337 .unwrap_or_default();
338
339 Some(IngestEvent::ToolCall {
340 name,
341 args,
342 result_summary,
343 timestamp,
344 rationale: rationale.map(str::to_string),
345 })
346}
347
348fn collect_tool_results(record: &Value, out: &mut HashMap<String, String>) {
352 let rt = record.get("type").and_then(|v| v.as_str());
353 if rt != Some("human") && rt != Some("user") {
354 return;
355 }
356 let Some(content) = record
357 .get("message")
358 .and_then(|m| m.get("content"))
359 .and_then(|c| c.as_array())
360 else {
361 return;
362 };
363 for block in content {
364 if block.get("type").and_then(|v| v.as_str()) != Some("tool_result") {
365 continue;
366 }
367 let Some(id) = block.get("tool_use_id").and_then(|v| v.as_str()) else {
368 continue;
369 };
370 if let Some(text) = extract_tool_result_text(block.get("content")) {
371 out.insert(id.to_string(), text);
372 }
373 }
374}
375
376fn extract_tool_result_text(content: Option<&Value>) -> Option<String> {
379 let content = content?;
380 if let Some(s) = content.as_str() {
381 let t = s.trim();
382 return if t.is_empty() {
383 None
384 } else {
385 Some(t.to_string())
386 };
387 }
388 if let Some(arr) = content.as_array() {
389 let mut parts = Vec::new();
390 for block in arr {
391 if block.get("type").and_then(|v| v.as_str()) == Some("text") {
392 if let Some(t) = block.get("text").and_then(|v| v.as_str()) {
393 if !t.trim().is_empty() {
394 parts.push(t.to_string());
395 }
396 }
397 }
398 }
399 return if parts.is_empty() {
400 None
401 } else {
402 Some(parts.join("\n"))
403 };
404 }
405 None
406}
407
408fn is_boilerplate(text: &str) -> bool {
410 const BOILERPLATE: &[&str] = &[
411 "y",
412 "yes",
413 "ok",
414 "okay",
415 "continue",
416 "go",
417 "go ahead",
418 "sure",
419 "great",
420 "thanks",
421 "thank you",
422 "looks good",
423 "lgtm",
424 "done",
425 "proceed",
426 "next",
427 "k",
428 "yep",
429 "yup",
430 ];
431 let lower = text.trim().to_lowercase();
432 BOILERPLATE.contains(&lower.as_str())
433}
434
435fn is_synthetic_user_text(text: &str) -> bool {
440 const PREFIXES: &[&str] = &[
441 "This session is being continued from a previous conversation",
442 "Caveat: The messages below were generated by the user while running",
443 "Base directory for this skill:",
445 ];
446 let trimmed = text.trim_start();
447 PREFIXES.iter().any(|p| trimmed.starts_with(p))
448 || (trimmed.starts_with('<')
453 && ["<command-message>", "<command-name>", "<local-command-stdout>",
454 "<system_instruction>", "<system-reminder>"]
455 .iter()
456 .any(|t| trimmed.starts_with(t)))
457}
458
459fn is_option_answer(text: &str) -> bool {
464 let lower = text.trim().to_lowercase();
465 if lower.len() == 1 && lower.as_bytes()[0].is_ascii_lowercase() {
467 return true;
468 }
469 matches!(
471 lower.as_str(),
472 "all" | "all of the above" | "none" | "none of the above" | "both" | "neither"
473 ) || lower
474 .strip_prefix("all ")
475 .is_some_and(|rest| rest.len() == 1 && rest.as_bytes()[0].is_ascii_lowercase())
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481 use std::io::Cursor;
482
483 #[test]
484 fn relativize_strips_cwd_prefix() {
485 assert_eq!(
486 relativize("/Users/dev/repo/src/auth.rs", Some("/Users/dev/repo")),
487 "src/auth.rs"
488 );
489 assert_eq!(
491 relativize("/Users/dev/repo/src/auth.rs", Some("/Users/dev/repo/")),
492 "src/auth.rs"
493 );
494 assert_eq!(
496 relativize("src/auth.rs", Some("/Users/dev/repo")),
497 "src/auth.rs"
498 );
499 assert_eq!(
501 relativize("/etc/passwd", Some("/Users/dev/repo")),
502 "/etc/passwd"
503 );
504 assert_eq!(relativize("src/auth.rs", None), "src/auth.rs");
506 }
507
508 #[test]
509 fn parses_user_type_records_with_absolute_paths() {
510 let jsonl = r#"{"type":"user","timestamp":"2026-01-01T00:00:00Z","uuid":"u1","cwd":"/Users/dev/repo","message":{"role":"user","content":"Add JWT verification"}}
513{"type":"assistant","timestamp":"2026-01-01T00:00:01Z","cwd":"/Users/dev/repo","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Edit","input":{"file_path":"/Users/dev/repo/src/auth.rs","old_string":"a","new_string":"b"}}]}}"#;
514 let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
515 assert!(events
517 .iter()
518 .any(|e| matches!(e, IngestEvent::UserPrompt { text, .. } if text.contains("JWT"))));
519 let path = events.iter().find_map(|e| match e {
521 IngestEvent::ToolCall { name, args, .. } if name == "Edit" => args
522 .get("file_path")
523 .and_then(|v| v.as_str())
524 .map(String::from),
525 _ => None,
526 });
527 assert_eq!(path.as_deref(), Some("src/auth.rs"));
528 }
529
530 #[test]
531 fn skips_synthetic_user_turns() {
532 assert!(is_synthetic_user_text(
535 "This session is being continued from a previous conversation that ran out of context.\n\nSummary:\n1. ..."
536 ));
537 assert!(is_synthetic_user_text(
538 "Caveat: The messages below were generated by the user while running local commands. DO NOT respond..."
539 ));
540 assert!(is_synthetic_user_text(
541 "<command-name>/clear</command-name>"
542 ));
543 assert!(!is_synthetic_user_text(
545 "Add JWT verification to the auth module"
546 ));
547 }
548
549 #[test]
550 fn compaction_summary_does_not_become_an_intent() {
551 let jsonl = r#"{"type":"user","timestamp":"2026-01-01T00:00:00Z","uuid":"u1","cwd":"/r","message":{"role":"user","content":"This session is being continued from a previous conversation that ran out of context.\n\nSummary: lots of recap text."}}
552{"type":"user","timestamp":"2026-01-01T00:01:00Z","uuid":"u2","cwd":"/r","message":{"role":"user","content":"Add rate limiting to the API"}}"#;
553 let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
554 let prompts: Vec<&str> = events
555 .iter()
556 .filter_map(|e| match e {
557 IngestEvent::UserPrompt { text, .. } => Some(text.as_str()),
558 _ => None,
559 })
560 .collect();
561 assert_eq!(prompts, vec!["Add rate limiting to the API"]);
562 }
563
564 #[test]
565 fn tool_use_captures_preceding_assistant_text_as_rationale() {
566 let jsonl = r#"{"type":"user","timestamp":"2026-01-01T00:00:00Z","uuid":"u1","cwd":"/r","message":{"role":"user","content":"fix auth"}}
569{"type":"assistant","timestamp":"2026-01-01T00:00:01Z","cwd":"/r","message":{"role":"assistant","content":[{"type":"text","text":"Now I'll add the JWT signature check."},{"type":"tool_use","id":"t1","name":"Edit","input":{"file_path":"/r/src/auth.rs","old_string":"a","new_string":"b"}}]}}"#;
570 let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
571 let rationale = events.iter().find_map(|e| match e {
572 IngestEvent::ToolCall { rationale, .. } => Some(rationale.clone()),
573 _ => None,
574 });
575 assert_eq!(
576 rationale,
577 Some(Some("Now I'll add the JWT signature check.".to_string()))
578 );
579 }
580
581 #[test]
582 fn tool_result_is_correlated_into_tool_call() {
583 let jsonl = r#"{"type":"human","timestamp":"2024-01-01T00:00:00Z","uuid":"u1","message":{"role":"user","content":"Read the auth file"}}
586{"type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"src/auth.rs"}}]}}
587{"type":"human","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"text","text":"pub fn authenticate() {}"}]}]}}"#;
588 let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
589 let read_result = events.iter().find_map(|e| match e {
590 IngestEvent::ToolCall {
591 name,
592 result_summary,
593 ..
594 } if name == "Read" => Some(result_summary.clone()),
595 _ => None,
596 });
597 assert_eq!(read_result.as_deref(), Some("pub fn authenticate() {}"));
598 }
599
600 #[test]
601 fn tool_call_without_result_has_empty_summary() {
602 let jsonl = r#"{"type":"human","timestamp":"2024-01-01T00:00:00Z","uuid":"u1","message":{"role":"user","content":"edit it"}}
604{"type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"t9","name":"Edit","input":{"file_path":"src/x.rs","old_string":"a","new_string":"b"}}]}}"#;
605 let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
606 let edit_result = events.iter().find_map(|e| match e {
607 IngestEvent::ToolCall {
608 name,
609 result_summary,
610 ..
611 } if name == "Edit" => Some(result_summary.clone()),
612 _ => None,
613 });
614 assert_eq!(edit_result.as_deref(), Some(""));
615 }
616
617 #[test]
618 fn parses_minimal_session() {
619 let jsonl = r#"{"type":"human","timestamp":"2024-01-01T00:00:00Z","uuid":"u1","message":{"role":"user","content":"Add JWT verification"}}
620{"type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Edit","input":{"file_path":"src/auth.rs","old_string":"","new_string":"// jwt"}}]}}
621"#;
622 let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
623 let has_user = events
624 .iter()
625 .any(|e| matches!(e, IngestEvent::UserPrompt { text, .. } if text.contains("JWT")));
626 let has_tool = events
627 .iter()
628 .any(|e| matches!(e, IngestEvent::ToolCall { name, .. } if name == "Edit"));
629 assert!(has_user, "should have UserPrompt");
630 assert!(has_tool, "should have ToolCall for Edit");
631 }
632
633 #[test]
634 fn skips_slash_commands() {
635 let jsonl = r#"{"type":"human","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"/autoplan implement the auth module"}}
636{"type":"human","timestamp":"2024-01-01T00:01:00Z","message":{"role":"user","content":"Add JWT verification"}}
637"#;
638 let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
639 let user_prompts: Vec<_> = events
640 .iter()
641 .filter(|e| matches!(e, IngestEvent::UserPrompt { .. }))
642 .collect();
643 assert_eq!(user_prompts.len(), 1);
644 assert!(
645 matches!(&user_prompts[0], IngestEvent::UserPrompt { text, .. } if text.contains("JWT"))
646 );
647 }
648
649 #[test]
650 fn rejects_dotdot_paths() {
651 let jsonl = r#"{"type":"human","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"test"}}
652{"type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Write","input":{"file_path":"../../etc/passwd"}}]}}
653"#;
654 let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
655 let has_tool = events
656 .iter()
657 .any(|e| matches!(e, IngestEvent::ToolCall { .. }));
658 assert!(!has_tool, "path with .. should be rejected");
659 }
660
661 #[test]
662 fn rejects_absolute_paths() {
663 let jsonl = r#"{"type":"human","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"test"}}
664{"type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Edit","input":{"file_path":"/etc/passwd"}}]}}
665"#;
666 let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
667 let has_tool = events
668 .iter()
669 .any(|e| matches!(e, IngestEvent::ToolCall { .. }));
670 assert!(!has_tool, "absolute path should be rejected");
671 }
672
673 #[test]
674 fn skips_boilerplate_user_messages() {
675 let jsonl = r#"{"type":"human","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"continue"}}
676{"type":"human","timestamp":"2024-01-01T00:01:00Z","message":{"role":"user","content":"Add JWT verification to auth"}}
677"#;
678 let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
679 let prompts: Vec<_> = events
680 .iter()
681 .filter(|e| matches!(e, IngestEvent::UserPrompt { .. }))
682 .collect();
683 assert_eq!(prompts.len(), 1);
684 }
685
686 #[test]
687 fn is_option_answer_matches_multiple_choice_picks() {
688 for yes in [
689 "A",
690 "b",
691 "all",
692 "all A",
693 "all b",
694 "none",
695 "Both",
696 "neither",
697 "All of the above",
698 "none of the above",
699 ] {
700 assert!(
701 is_option_answer(yes),
702 "should treat {yes:?} as an MC answer"
703 );
704 }
705 for no in [
706 "add tests",
707 "fix",
708 "abc",
709 "all tests pass",
710 "a and b",
711 "B is wrong",
712 ] {
713 assert!(
714 !is_option_answer(no),
715 "should NOT treat {no:?} as an MC answer"
716 );
717 }
718 }
719
720 #[test]
721 fn option_answer_rolls_tool_calls_into_prior_intent() {
722 let jsonl = r#"{"type":"user","timestamp":"2024-01-01T00:00:00Z","cwd":"/r","message":{"role":"user","content":"Refactor the auth module"}}
725{"type":"assistant","timestamp":"2024-01-01T00:00:01Z","cwd":"/r","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"/r/src/auth.rs"}}]}}
726{"type":"user","timestamp":"2024-01-01T00:01:00Z","cwd":"/r","message":{"role":"user","content":"A"}}
727{"type":"assistant","timestamp":"2024-01-01T00:01:01Z","cwd":"/r","message":{"role":"assistant","content":[{"type":"tool_use","id":"t2","name":"Edit","input":{"file_path":"/r/src/auth.rs","old_string":"x","new_string":"y"}}]}}
728"#;
729 let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
730 let prompts: Vec<_> = events
731 .iter()
732 .filter_map(|e| match e {
733 IngestEvent::UserPrompt { text, .. } => Some(text.as_str()),
734 _ => None,
735 })
736 .collect();
737 assert_eq!(
738 prompts,
739 vec!["Refactor the auth module"],
740 "\"A\" must not open an intent"
741 );
742 }
743
744 #[test]
745 fn synthetic_user_text_flags_injected_wrappers() {
746 for s in [
747 "Base directory for this skill: /x",
748 "<command-message>ship</command-message>\n<command-name>/ship</command-name>",
749 "<system_instruction>\nYou are working inside Conductor",
750 "<system-reminder>do a thing</system-reminder>",
751 "This session is being continued from a previous conversation",
752 ] {
753 assert!(is_synthetic_user_text(s), "should flag {s:?}");
754 }
755 assert!(!is_synthetic_user_text(
757 "A. a real session lives here: /tmp/x"
758 ));
759 assert!(!is_synthetic_user_text("Refactor the auth module"));
760 }
761
762 #[test]
763 fn skips_skill_invocation_preamble() {
764 let jsonl = r#"{"type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":[{"type":"text","text":"Base directory for this skill: /Users/x/.claude/skills/autoplan\n\n## When to invoke"}]}}
766{"type":"user","timestamp":"2024-01-01T00:01:00Z","message":{"role":"user","content":"Add JWT verification to auth"}}
767"#;
768 let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
769 let prompts: Vec<_> = events
770 .iter()
771 .filter_map(|e| match e {
772 IngestEvent::UserPrompt { text, .. } => Some(text.as_str()),
773 _ => None,
774 })
775 .collect();
776 assert_eq!(prompts, vec!["Add JWT verification to auth"]);
777 }
778
779 #[test]
780 fn parses_system_record_as_system_instruction() {
781 let jsonl = r#"{"type":"system","timestamp":"2024-01-01T00:00:00Z","message":{"role":"system","content":"You must validate all inputs."}}
782{"type":"human","timestamp":"2024-01-01T00:01:00Z","message":{"role":"user","content":"Implement it"}}
783"#;
784 let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
785 let sys: Vec<_> = events
786 .iter()
787 .filter(|e| matches!(e, IngestEvent::SystemInstruction { .. }))
788 .collect();
789 assert!(
790 !sys.is_empty(),
791 "system record should produce SystemInstruction"
792 );
793 }
794
795 #[test]
796 fn parses_summary_record_as_system_instruction() {
797 let jsonl = r#"{"type":"summary","timestamp":"2024-01-01T00:00:00Z","summary":"Prior work: implemented JWT verification."}
798{"type":"human","timestamp":"2024-01-01T00:01:00Z","message":{"role":"user","content":"Continue with tests"}}
799"#;
800 let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
801 let sys: Vec<_> = events
802 .iter()
803 .filter(|e| matches!(e, IngestEvent::SystemInstruction { .. }))
804 .collect();
805 assert!(
806 !sys.is_empty(),
807 "summary record should produce SystemInstruction"
808 );
809 }
810
811 #[test]
812 fn gracefully_handles_malformed_json_line() {
813 let jsonl = "this is not json\n{\"type\":\"human\",\"timestamp\":\"2024-01-01T00:00:00Z\",\"message\":{\"role\":\"user\",\"content\":\"Add JWT verification\"}}\n";
814 let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
815 let prompts: Vec<_> = events
817 .iter()
818 .filter(|e| matches!(e, IngestEvent::UserPrompt { .. }))
819 .collect();
820 assert_eq!(prompts.len(), 1, "should still parse the valid line");
821 }
822
823 #[test]
824 fn content_as_bare_string_is_extracted() {
825 let jsonl = r#"{"type":"human","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Add JWT auth to the module"}}
826"#;
827 let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
828 let prompts: Vec<_> = events
829 .iter()
830 .filter(|e| matches!(e, IngestEvent::UserPrompt { text, .. } if text.contains("JWT")))
831 .collect();
832 assert_eq!(prompts.len(), 1);
833 }
834
835 #[test]
836 fn tool_result_blocks_are_skipped_as_user_intent() {
837 let jsonl = r#"{"type":"human","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"real intent"}}
839{"type":"human","timestamp":"2024-01-01T00:01:00Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"text","text":"Edit succeeded."}]}]}}
840"#;
841 let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
842 let prompts: Vec<_> = events
843 .iter()
844 .filter(|e| matches!(e, IngestEvent::UserPrompt { .. }))
845 .collect();
846 assert_eq!(
847 prompts.len(),
848 1,
849 "tool_result turn should not produce UserPrompt"
850 );
851 }
852
853 #[test]
854 fn unknown_tool_name_is_filtered_out() {
855 let jsonl = r#"{"type":"human","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"test"}}
856{"type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"UnknownTool","input":{"arg":"val"}}]}}
857"#;
858 let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
859 let tools: Vec<_> = events
860 .iter()
861 .filter(|e| matches!(e, IngestEvent::ToolCall { .. }))
862 .collect();
863 assert!(tools.is_empty(), "unknown tool should be filtered out");
864 }
865}