vasari-core 0.2.2

Content-addressed intent-graph library behind Vasari — intent attribution for autonomous coding agents.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
/// Adapter for Claude Code session JSONL files.
///
/// Claude Code writes each conversation turn as a newline-delimited JSON record
/// at `~/.claude/projects/<session>/<timestamp>.jsonl`.  Each record has a
/// top-level `type` ("user" | "assistant" | "summary" | "system"; older exports
/// used "human" for the user turn, still accepted) and a `message` sub-object in
/// the Claude Messages API format. User/assistant records also carry a `cwd`,
/// against which absolute tool `file_path`s are relativized at ingest.
///
/// Filtering rules that match the ingest plan:
///   • Skip records whose user message begins with "/" (slash-command invocations).
///   • Skip queue-operation records (tool_result entries with no user-visible text).
///   • Skip user text that isn't a fresh intent — boilerplate ("continue"),
///     multiple-choice answers ("A", "all B"), and injected wrappers (compaction
///     recaps, skill preambles, `<system_instruction>`/`<command-*>` tags) — so
///     their tool calls roll up to the prior real request instead of starting one.
///   • The *first* non-command, non-empty user text becomes the session Intent.
///   • ToolCall events are emitted for Edit, Write, MultiEdit, Bash, and Read.
///   • Edit / Write / MultiEdit also produce an AttributionTarget via run_pipeline.
use std::collections::HashMap;
use std::io::{BufRead, BufReader};

use chrono::{DateTime, Utc};
use serde_json::Value;

use crate::{
    error::VasariError,
    ingest::{IngestAdapter, IngestEvent, IngestSource},
};

pub struct ClaudeCodeAdapter;

impl IngestAdapter for ClaudeCodeAdapter {
    fn parse(&self, source: IngestSource) -> Result<Vec<IngestEvent>, VasariError> {
        let reader: Box<dyn BufRead> = match source {
            IngestSource::File(path) => {
                let file = std::fs::File::open(&path).map_err(VasariError::Io)?;
                Box::new(BufReader::new(file))
            }
            IngestSource::Stdin => Box::new(BufReader::new(std::io::stdin())),
        };

        parse_jsonl(reader)
    }
}

fn parse_jsonl(reader: impl BufRead) -> Result<Vec<IngestEvent>, VasariError> {
    let mut events: Vec<IngestEvent> = Vec::new();
    let mut session_start: Option<DateTime<Utc>> = None;
    let mut session_source: Option<String> = None;
    let mut found_intent = false;

    // Parse every line up front. A tool_use block (assistant record) and its
    // tool_result (the *following* human record) are correlated only by
    // tool_use_id, so we need the whole document before emitting ToolCall events.
    let mut records: Vec<Value> = Vec::new();
    for (idx, line) in reader.lines().enumerate() {
        let line_no = idx as u64 + 1;
        let line = line.map_err(VasariError::Io)?;
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        match serde_json::from_str::<Value>(line) {
            Ok(v) => records.push(v),
            // Graceful degradation: record the parse error, keep going.
            Err(e) => events.push(IngestEvent::SystemInstruction {
                text: format!("[parse error on line {line_no}: {e}]"),
            }),
        }
    }

    // Pass 1: build tool_use_id -> result-text from tool_result blocks. This is
    // where Read results (file contents) and Edit/Write confirmations live.
    let mut results: HashMap<String, String> = HashMap::new();
    for record in &records {
        collect_tool_results(record, &mut results);
    }

    // The agent's stated reason for the tool calls that follow it. Real sessions
    // split each `thinking` / `text` / `tool_use` block into its *own* assistant
    // record, so rationale must be tracked across records (in document order),
    // not within a single message. Reset at each new user turn so one turn's
    // rationale never bleeds into the next.
    let mut rationale: Option<String> = None;

    // Pass 2: emit events in document order.
    for record in &records {
        // Extract timestamp from the record (top-level "timestamp" field).
        let timestamp = record
            .get("timestamp")
            .and_then(|v| v.as_str())
            .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
            .map(|dt| dt.with_timezone(&Utc))
            .unwrap_or_else(Utc::now);

        // Track the earliest timestamp as session start.
        if session_start.map(|st| timestamp < st).unwrap_or(true) {
            session_start = Some(timestamp);
        }

        let record_type = record.get("type").and_then(|v| v.as_str()).unwrap_or("");

        match record_type {
            "system" => {
                // System prompt — contains CLAUDE.md or other instructions.
                if let Some(text) = extract_text_from_record(record) {
                    events.push(IngestEvent::SystemInstruction { text });
                }
            }
            "summary" => {
                // Claude Code compresses old context into a summary record.
                // Treat the summary as a SystemInstruction so constraints in it are captured.
                if let Some(text) = record.get("summary").and_then(|v| v.as_str()) {
                    events.push(IngestEvent::SystemInstruction {
                        text: text.to_string(),
                    });
                }
            }
            "human" | "user" => {
                // Human / user turn.  May contain a user text message or tool results.
                let message = match record.get("message") {
                    Some(m) => m,
                    None => continue,
                };

                let content = message.get("content");

                // Check for plain-text user messages.
                let text = extract_user_text(content);

                if let Some(text) = text {
                    // Skip slash-command invocations (e.g. "/autoplan …", "/clear").
                    if text.starts_with('/') {
                        continue;
                    }
                    // Skip very short boilerplate ("continue", "y", "ok", etc.).
                    if is_boilerplate(&text) {
                        continue;
                    }
                    // Skip multiple-choice answers ("A", "all B", …). These are
                    // real replies to a question the agent asked *within* a task,
                    // not new intents — so let the tool calls that follow roll up
                    // to the prior substantive request instead of "A".
                    if is_option_answer(&text) {
                        continue;
                    }
                    // Skip auto-generated "user" turns (context-compaction recaps,
                    // local-command output) — these are not the developer's intent
                    // and otherwise pollute `vasari why` with multi-paragraph noise.
                    if is_synthetic_user_text(&text) {
                        continue;
                    }

                    let ts = record
                        .get("timestamp")
                        .and_then(|v| v.as_str())
                        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
                        .map(|dt| dt.with_timezone(&Utc))
                        .unwrap_or(timestamp);

                    if !found_intent {
                        // Set the session source label from the first substantive message.
                        let source_label = record
                            .get("uuid")
                            .and_then(|v| v.as_str())
                            .map(|s| format!("claude-code:{s}"))
                            .unwrap_or_else(|| "claude-code:unknown".to_string());
                        session_source = Some(source_label);
                        found_intent = true;
                    }

                    // A new turn begins — the agent hasn't stated a reason yet.
                    rationale = None;

                    events.push(IngestEvent::UserPrompt {
                        text,
                        timestamp: ts,
                    });
                }
            }
            "assistant" => {
                // Assistant turn — look for tool_use blocks.
                let message = match record.get("message") {
                    Some(m) => m,
                    None => continue,
                };

                // Claude Code records the agent's working directory per record;
                // tool `file_path`s are absolute, so relativize against it.
                let cwd = record.get("cwd").and_then(|v| v.as_str());

                let content = message.get("content");
                if let Some(content_arr) = content.and_then(|c| c.as_array()) {
                    // The agent narrates its reason in a `text` (or `thinking`)
                    // block, then issues the `tool_use` — usually in the *next*
                    // record. Carry the most recent narration forward as the
                    // rationale for the calls that follow it.
                    for block in content_arr {
                        match block.get("type").and_then(|v| v.as_str()) {
                            Some("text") | Some("thinking") => {
                                let field = if block.get("text").is_some() {
                                    "text"
                                } else {
                                    "thinking"
                                };
                                if let Some(t) = block.get(field).and_then(|v| v.as_str()) {
                                    let t = t.trim();
                                    if !t.is_empty() {
                                        rationale = Some(t.to_string());
                                    }
                                }
                            }
                            Some("tool_use") => {
                                if let Some(ev) = parse_tool_use_block(
                                    block,
                                    timestamp,
                                    &results,
                                    cwd,
                                    rationale.as_deref(),
                                ) {
                                    events.push(ev);
                                }
                            }
                            _ => {}
                        }
                    }
                }
            }
            _ => {
                // Unknown record type — skip.
            }
        }
    }

    // Prepend SessionStart now that we know the source label and start time.
    let source = session_source.unwrap_or_else(|| "claude-code:unknown".to_string());
    let started_at = session_start.unwrap_or_else(Utc::now);
    events.insert(0, IngestEvent::SessionStart { source, started_at });

    Ok(events)
}

/// Extract a plain-text string from a user message content field.
/// Content can be a bare string or an array of blocks.
fn extract_user_text(content: Option<&Value>) -> Option<String> {
    let content = content?;
    if let Some(s) = content.as_str() {
        let t = s.trim().to_string();
        return if t.is_empty() { None } else { Some(t) };
    }
    if let Some(arr) = content.as_array() {
        // Collect all text blocks; skip tool_result blocks.
        let mut parts = Vec::new();
        for block in arr {
            let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
            match block_type {
                "text" => {
                    if let Some(t) = block.get("text").and_then(|v| v.as_str()) {
                        let t = t.trim();
                        if !t.is_empty() {
                            parts.push(t.to_string());
                        }
                    }
                }
                "tool_result" => {
                    // Tool results are not user intent — skip.
                }
                _ => {}
            }
        }
        if parts.is_empty() {
            return None;
        }
        return Some(parts.join("\n"));
    }
    None
}

fn extract_text_from_record(record: &Value) -> Option<String> {
    let message = record.get("message")?;
    extract_user_text(message.get("content"))
}

/// Make an absolute tool `file_path` repo-relative by stripping the agent's
/// working directory. Already-relative paths pass through unchanged.
fn relativize(path: &str, cwd: Option<&str>) -> String {
    if let Some(cwd) = cwd {
        let prefix = format!("{}/", cwd.trim_end_matches('/'));
        if let Some(rest) = path.strip_prefix(&prefix) {
            return rest.to_string();
        }
    }
    path.to_string()
}

fn parse_tool_use_block(
    block: &Value,
    timestamp: DateTime<Utc>,
    results: &HashMap<String, String>,
    cwd: Option<&str>,
    rationale: Option<&str>,
) -> Option<IngestEvent> {
    let name = block.get("name").and_then(|v| v.as_str())?.to_string();

    // Only ingest tools we understand.
    match name.as_str() {
        "Edit" | "Write" | "MultiEdit" | "Bash" | "Read" | "Glob" | "Grep" => {}
        _ => return None,
    }

    let mut args = block
        .get("input")
        .cloned()
        .unwrap_or(Value::Object(Default::default()));

    // Tool `file_path`s are absolute in real sessions; relativize against the
    // record's cwd so they match the repo-relative paths `vasari why` queries.
    if let Some(path) = args.get("file_path").and_then(|v| v.as_str()) {
        let rel = relativize(path, cwd);
        // For tools with paths, validate the path doesn't escape the repo
        // (shared rule — see ingest::is_safe_path).
        if !crate::ingest::is_safe_path(&rel) {
            return None;
        }
        args["file_path"] = Value::String(rel);
    }

    // Correlate this tool_use with its tool_result (keyed by id). For Read this
    // is the file content; for Edit/Write it is the success confirmation. Empty
    // when the session has no matching result (e.g. truncated export).
    let result_summary = block
        .get("id")
        .and_then(|v| v.as_str())
        .and_then(|id| results.get(id))
        .cloned()
        .unwrap_or_default();

    Some(IngestEvent::ToolCall {
        name,
        args,
        result_summary,
        timestamp,
        rationale: rationale.map(str::to_string),
    })
}

/// Collect tool_result blocks from a human record into `out` (tool_use_id -> text).
/// Claude Code attaches each tool's result to the *following* human turn as a
/// `tool_result` block keyed by `tool_use_id`.
fn collect_tool_results(record: &Value, out: &mut HashMap<String, String>) {
    let rt = record.get("type").and_then(|v| v.as_str());
    if rt != Some("human") && rt != Some("user") {
        return;
    }
    let Some(content) = record
        .get("message")
        .and_then(|m| m.get("content"))
        .and_then(|c| c.as_array())
    else {
        return;
    };
    for block in content {
        if block.get("type").and_then(|v| v.as_str()) != Some("tool_result") {
            continue;
        }
        let Some(id) = block.get("tool_use_id").and_then(|v| v.as_str()) else {
            continue;
        };
        if let Some(text) = extract_tool_result_text(block.get("content")) {
            out.insert(id.to_string(), text);
        }
    }
}

/// Extract the textual payload of a tool_result `content` field, which may be a
/// bare string or an array of `{type:"text", text:"..."}` blocks.
fn extract_tool_result_text(content: Option<&Value>) -> Option<String> {
    let content = content?;
    if let Some(s) = content.as_str() {
        let t = s.trim();
        return if t.is_empty() {
            None
        } else {
            Some(t.to_string())
        };
    }
    if let Some(arr) = content.as_array() {
        let mut parts = Vec::new();
        for block in arr {
            if block.get("type").and_then(|v| v.as_str()) == Some("text") {
                if let Some(t) = block.get("text").and_then(|v| v.as_str()) {
                    if !t.trim().is_empty() {
                        parts.push(t.to_string());
                    }
                }
            }
        }
        return if parts.is_empty() {
            None
        } else {
            Some(parts.join("\n"))
        };
    }
    None
}

/// Very short single-word responses that don't carry intent.
fn is_boilerplate(text: &str) -> bool {
    const BOILERPLATE: &[&str] = &[
        "y",
        "yes",
        "ok",
        "okay",
        "continue",
        "go",
        "go ahead",
        "sure",
        "great",
        "thanks",
        "thank you",
        "looks good",
        "lgtm",
        "done",
        "proceed",
        "next",
        "k",
        "yep",
        "yup",
    ];
    let lower = text.trim().to_lowercase();
    BOILERPLATE.contains(&lower.as_str())
}

/// Auto-generated "user" turns that aren't the developer's intent: context
/// compaction recaps and local-command output that Claude Code injects as user
/// messages. Treating these as intents fills `vasari why` with multi-paragraph
/// summaries instead of the actual request.
fn is_synthetic_user_text(text: &str) -> bool {
    const PREFIXES: &[&str] = &[
        "This session is being continued from a previous conversation",
        "Caveat: The messages below were generated by the user while running",
        // Invoking a `/skill` injects the skill's SKILL.md as a user message.
        "Base directory for this skill:",
    ];
    let trimmed = text.trim_start();
    PREFIXES.iter().any(|p| trimmed.starts_with(p))
        // Injected wrapper tags: slash-command expansion (`<command-message>` /
        // `<command-name>` / `<local-command-stdout>`) and the harness system
        // prompt (`<system_instruction>` / `<system-reminder>`) — none are the
        // developer's intent.
        || (trimmed.starts_with('<')
            && ["<command-message>", "<command-name>", "<local-command-stdout>",
                "<system_instruction>", "<system-reminder>"]
                .iter()
                .any(|t| trimmed.starts_with(t)))
}

/// A multiple-choice answer to a question the agent asked mid-task (e.g. "A",
/// "all B", "none of the above"). These are genuine user input but continue the
/// prior request rather than opening a new intent, so — like [`is_boilerplate`] —
/// they should not become an Intent of their own.
fn is_option_answer(text: &str) -> bool {
    let lower = text.trim().to_lowercase();
    // A single option letter: "a", "b", … (an MC pick, not an instruction).
    if lower.len() == 1 && lower.as_bytes()[0].is_ascii_lowercase() {
        return true;
    }
    // "all", "all a", "all of the above", "none[ of the above]", "both", "neither".
    matches!(
        lower.as_str(),
        "all" | "all of the above" | "none" | "none of the above" | "both" | "neither"
    ) || lower
        .strip_prefix("all ")
        .is_some_and(|rest| rest.len() == 1 && rest.as_bytes()[0].is_ascii_lowercase())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Cursor;

    #[test]
    fn relativize_strips_cwd_prefix() {
        assert_eq!(
            relativize("/Users/dev/repo/src/auth.rs", Some("/Users/dev/repo")),
            "src/auth.rs"
        );
        // Trailing slash on cwd is tolerated.
        assert_eq!(
            relativize("/Users/dev/repo/src/auth.rs", Some("/Users/dev/repo/")),
            "src/auth.rs"
        );
        // Already-relative paths pass through.
        assert_eq!(
            relativize("src/auth.rs", Some("/Users/dev/repo")),
            "src/auth.rs"
        );
        // Path outside cwd is left untouched (is_safe_path rejects it later).
        assert_eq!(
            relativize("/etc/passwd", Some("/Users/dev/repo")),
            "/etc/passwd"
        );
        // No cwd → unchanged.
        assert_eq!(relativize("src/auth.rs", None), "src/auth.rs");
    }

    #[test]
    fn parses_user_type_records_with_absolute_paths() {
        // Real Claude Code sessions use `type:"user"` (not `"human"`) and record
        // absolute file_paths under a per-record `cwd`. Both must be handled.
        let jsonl = r#"{"type":"user","timestamp":"2026-01-01T00:00:00Z","uuid":"u1","cwd":"/Users/dev/repo","message":{"role":"user","content":"Add JWT verification"}}
{"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"}}]}}"#;
        let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
        // The user prompt becomes an intent-bearing UserPrompt.
        assert!(events
            .iter()
            .any(|e| matches!(e, IngestEvent::UserPrompt { text, .. } if text.contains("JWT"))));
        // The Edit survives and its path is repo-relative.
        let path = events.iter().find_map(|e| match e {
            IngestEvent::ToolCall { name, args, .. } if name == "Edit" => args
                .get("file_path")
                .and_then(|v| v.as_str())
                .map(String::from),
            _ => None,
        });
        assert_eq!(path.as_deref(), Some("src/auth.rs"));
    }

    #[test]
    fn skips_synthetic_user_turns() {
        // Context-compaction recaps and command-output caveats are injected as
        // `user` records but are not the developer's intent.
        assert!(is_synthetic_user_text(
            "This session is being continued from a previous conversation that ran out of context.\n\nSummary:\n1. ..."
        ));
        assert!(is_synthetic_user_text(
            "Caveat: The messages below were generated by the user while running local commands. DO NOT respond..."
        ));
        assert!(is_synthetic_user_text(
            "<command-name>/clear</command-name>"
        ));
        // A genuine request is not synthetic.
        assert!(!is_synthetic_user_text(
            "Add JWT verification to the auth module"
        ));
    }

    #[test]
    fn compaction_summary_does_not_become_an_intent() {
        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."}}
{"type":"user","timestamp":"2026-01-01T00:01:00Z","uuid":"u2","cwd":"/r","message":{"role":"user","content":"Add rate limiting to the API"}}"#;
        let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
        let prompts: Vec<&str> = events
            .iter()
            .filter_map(|e| match e {
                IngestEvent::UserPrompt { text, .. } => Some(text.as_str()),
                _ => None,
            })
            .collect();
        assert_eq!(prompts, vec!["Add rate limiting to the API"]);
    }

    #[test]
    fn tool_use_captures_preceding_assistant_text_as_rationale() {
        // The text block immediately before a tool_use is the agent's stated
        // reason for the call.
        let jsonl = r#"{"type":"user","timestamp":"2026-01-01T00:00:00Z","uuid":"u1","cwd":"/r","message":{"role":"user","content":"fix auth"}}
{"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"}}]}}"#;
        let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
        let rationale = events.iter().find_map(|e| match e {
            IngestEvent::ToolCall { rationale, .. } => Some(rationale.clone()),
            _ => None,
        });
        assert_eq!(
            rationale,
            Some(Some("Now I'll add the JWT signature check.".to_string()))
        );
    }

    #[test]
    fn tool_result_is_correlated_into_tool_call() {
        // Read's result (file content) arrives in the *next* human record,
        // keyed by tool_use_id. The adapter must thread it onto the ToolCall.
        let jsonl = r#"{"type":"human","timestamp":"2024-01-01T00:00:00Z","uuid":"u1","message":{"role":"user","content":"Read the auth file"}}
{"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"}}]}}
{"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() {}"}]}]}}"#;
        let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
        let read_result = events.iter().find_map(|e| match e {
            IngestEvent::ToolCall {
                name,
                result_summary,
                ..
            } if name == "Read" => Some(result_summary.clone()),
            _ => None,
        });
        assert_eq!(read_result.as_deref(), Some("pub fn authenticate() {}"));
    }

    #[test]
    fn tool_call_without_result_has_empty_summary() {
        // No matching tool_result (e.g. truncated export) → empty, not a panic.
        let jsonl = r#"{"type":"human","timestamp":"2024-01-01T00:00:00Z","uuid":"u1","message":{"role":"user","content":"edit it"}}
{"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"}}]}}"#;
        let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
        let edit_result = events.iter().find_map(|e| match e {
            IngestEvent::ToolCall {
                name,
                result_summary,
                ..
            } if name == "Edit" => Some(result_summary.clone()),
            _ => None,
        });
        assert_eq!(edit_result.as_deref(), Some(""));
    }

    #[test]
    fn parses_minimal_session() {
        let jsonl = r#"{"type":"human","timestamp":"2024-01-01T00:00:00Z","uuid":"u1","message":{"role":"user","content":"Add JWT verification"}}
{"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"}}]}}
"#;
        let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
        let has_user = events
            .iter()
            .any(|e| matches!(e, IngestEvent::UserPrompt { text, .. } if text.contains("JWT")));
        let has_tool = events
            .iter()
            .any(|e| matches!(e, IngestEvent::ToolCall { name, .. } if name == "Edit"));
        assert!(has_user, "should have UserPrompt");
        assert!(has_tool, "should have ToolCall for Edit");
    }

    #[test]
    fn skips_slash_commands() {
        let jsonl = r#"{"type":"human","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"/autoplan implement the auth module"}}
{"type":"human","timestamp":"2024-01-01T00:01:00Z","message":{"role":"user","content":"Add JWT verification"}}
"#;
        let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
        let user_prompts: Vec<_> = events
            .iter()
            .filter(|e| matches!(e, IngestEvent::UserPrompt { .. }))
            .collect();
        assert_eq!(user_prompts.len(), 1);
        assert!(
            matches!(&user_prompts[0], IngestEvent::UserPrompt { text, .. } if text.contains("JWT"))
        );
    }

    #[test]
    fn rejects_dotdot_paths() {
        let jsonl = r#"{"type":"human","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"test"}}
{"type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Write","input":{"file_path":"../../etc/passwd"}}]}}
"#;
        let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
        let has_tool = events
            .iter()
            .any(|e| matches!(e, IngestEvent::ToolCall { .. }));
        assert!(!has_tool, "path with .. should be rejected");
    }

    #[test]
    fn rejects_absolute_paths() {
        let jsonl = r#"{"type":"human","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"test"}}
{"type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Edit","input":{"file_path":"/etc/passwd"}}]}}
"#;
        let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
        let has_tool = events
            .iter()
            .any(|e| matches!(e, IngestEvent::ToolCall { .. }));
        assert!(!has_tool, "absolute path should be rejected");
    }

    #[test]
    fn skips_boilerplate_user_messages() {
        let jsonl = r#"{"type":"human","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"continue"}}
{"type":"human","timestamp":"2024-01-01T00:01:00Z","message":{"role":"user","content":"Add JWT verification to auth"}}
"#;
        let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
        let prompts: Vec<_> = events
            .iter()
            .filter(|e| matches!(e, IngestEvent::UserPrompt { .. }))
            .collect();
        assert_eq!(prompts.len(), 1);
    }

    #[test]
    fn is_option_answer_matches_multiple_choice_picks() {
        for yes in [
            "A",
            "b",
            "all",
            "all A",
            "all b",
            "none",
            "Both",
            "neither",
            "All of the above",
            "none of the above",
        ] {
            assert!(
                is_option_answer(yes),
                "should treat {yes:?} as an MC answer"
            );
        }
        for no in [
            "add tests",
            "fix",
            "abc",
            "all tests pass",
            "a and b",
            "B is wrong",
        ] {
            assert!(
                !is_option_answer(no),
                "should NOT treat {no:?} as an MC answer"
            );
        }
    }

    #[test]
    fn option_answer_rolls_tool_calls_into_prior_intent() {
        // The agent asks a question mid-task; the user answers "A". The edit that
        // follows must attribute to the real request, not to "A".
        let jsonl = r#"{"type":"user","timestamp":"2024-01-01T00:00:00Z","cwd":"/r","message":{"role":"user","content":"Refactor the auth module"}}
{"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"}}]}}
{"type":"user","timestamp":"2024-01-01T00:01:00Z","cwd":"/r","message":{"role":"user","content":"A"}}
{"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"}}]}}
"#;
        let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
        let prompts: Vec<_> = events
            .iter()
            .filter_map(|e| match e {
                IngestEvent::UserPrompt { text, .. } => Some(text.as_str()),
                _ => None,
            })
            .collect();
        assert_eq!(
            prompts,
            vec!["Refactor the auth module"],
            "\"A\" must not open an intent"
        );
    }

    #[test]
    fn synthetic_user_text_flags_injected_wrappers() {
        for s in [
            "Base directory for this skill: /x",
            "<command-message>ship</command-message>\n<command-name>/ship</command-name>",
            "<system_instruction>\nYou are working inside Conductor",
            "<system-reminder>do a thing</system-reminder>",
            "This session is being continued from a previous conversation",
        ] {
            assert!(is_synthetic_user_text(s), "should flag {s:?}");
        }
        // A real message that merely starts with a known letter/word is kept.
        assert!(!is_synthetic_user_text(
            "A. a real session lives here: /tmp/x"
        ));
        assert!(!is_synthetic_user_text("Refactor the auth module"));
    }

    #[test]
    fn skips_skill_invocation_preamble() {
        // Invoking a /skill injects the skill's SKILL.md as a user message.
        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"}]}}
{"type":"user","timestamp":"2024-01-01T00:01:00Z","message":{"role":"user","content":"Add JWT verification to auth"}}
"#;
        let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
        let prompts: Vec<_> = events
            .iter()
            .filter_map(|e| match e {
                IngestEvent::UserPrompt { text, .. } => Some(text.as_str()),
                _ => None,
            })
            .collect();
        assert_eq!(prompts, vec!["Add JWT verification to auth"]);
    }

    #[test]
    fn parses_system_record_as_system_instruction() {
        let jsonl = r#"{"type":"system","timestamp":"2024-01-01T00:00:00Z","message":{"role":"system","content":"You must validate all inputs."}}
{"type":"human","timestamp":"2024-01-01T00:01:00Z","message":{"role":"user","content":"Implement it"}}
"#;
        let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
        let sys: Vec<_> = events
            .iter()
            .filter(|e| matches!(e, IngestEvent::SystemInstruction { .. }))
            .collect();
        assert!(
            !sys.is_empty(),
            "system record should produce SystemInstruction"
        );
    }

    #[test]
    fn parses_summary_record_as_system_instruction() {
        let jsonl = r#"{"type":"summary","timestamp":"2024-01-01T00:00:00Z","summary":"Prior work: implemented JWT verification."}
{"type":"human","timestamp":"2024-01-01T00:01:00Z","message":{"role":"user","content":"Continue with tests"}}
"#;
        let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
        let sys: Vec<_> = events
            .iter()
            .filter(|e| matches!(e, IngestEvent::SystemInstruction { .. }))
            .collect();
        assert!(
            !sys.is_empty(),
            "summary record should produce SystemInstruction"
        );
    }

    #[test]
    fn gracefully_handles_malformed_json_line() {
        let jsonl = "this is not json\n{\"type\":\"human\",\"timestamp\":\"2024-01-01T00:00:00Z\",\"message\":{\"role\":\"user\",\"content\":\"Add JWT verification\"}}\n";
        let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
        // Should not error; malformed line becomes a SystemInstruction with parse error note
        let prompts: Vec<_> = events
            .iter()
            .filter(|e| matches!(e, IngestEvent::UserPrompt { .. }))
            .collect();
        assert_eq!(prompts.len(), 1, "should still parse the valid line");
    }

    #[test]
    fn content_as_bare_string_is_extracted() {
        let jsonl = r#"{"type":"human","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Add JWT auth to the module"}}
"#;
        let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
        let prompts: Vec<_> = events
            .iter()
            .filter(|e| matches!(e, IngestEvent::UserPrompt { text, .. } if text.contains("JWT")))
            .collect();
        assert_eq!(prompts.len(), 1);
    }

    #[test]
    fn tool_result_blocks_are_skipped_as_user_intent() {
        // A human turn containing only tool_result blocks should not produce a UserPrompt
        let jsonl = r#"{"type":"human","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"real intent"}}
{"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."}]}]}}
"#;
        let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
        let prompts: Vec<_> = events
            .iter()
            .filter(|e| matches!(e, IngestEvent::UserPrompt { .. }))
            .collect();
        assert_eq!(
            prompts.len(),
            1,
            "tool_result turn should not produce UserPrompt"
        );
    }

    #[test]
    fn unknown_tool_name_is_filtered_out() {
        let jsonl = r#"{"type":"human","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"test"}}
{"type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"UnknownTool","input":{"arg":"val"}}]}}
"#;
        let events = parse_jsonl(Cursor::new(jsonl)).unwrap();
        let tools: Vec<_> = events
            .iter()
            .filter(|e| matches!(e, IngestEvent::ToolCall { .. }))
            .collect();
        assert!(tools.is_empty(), "unknown tool should be filtered out");
    }
}