Skip to main content

vasari_core/adapters/
claude_code.rs

1/// Adapter for Claude Code session JSONL files.
2///
3/// Claude Code writes each conversation turn as a newline-delimited JSON record
4/// at `~/.claude/projects/<session>/<timestamp>.jsonl`.  Each record has a
5/// top-level `type` ("user" | "assistant" | "summary" | "system"; older exports
6/// used "human" for the user turn, still accepted) and a `message` sub-object in
7/// the Claude Messages API format. User/assistant records also carry a `cwd`,
8/// against which absolute tool `file_path`s are relativized at ingest.
9///
10/// Filtering rules that match the ingest plan:
11///   • Skip records whose user message begins with "/" (slash-command invocations).
12///   • Skip queue-operation records (tool_result entries with no user-visible text).
13///   • Skip user text that isn't a fresh intent — boilerplate ("continue"),
14///     multiple-choice answers ("A", "all B"), and injected wrappers (compaction
15///     recaps, skill preambles, `<system_instruction>`/`<command-*>` tags) — so
16///     their tool calls roll up to the prior real request instead of starting one.
17///   • The *first* non-command, non-empty user text becomes the session Intent.
18///   • ToolCall events are emitted for Edit, Write, MultiEdit, Bash, and Read.
19///   • Edit / Write / MultiEdit also produce an AttributionTarget via run_pipeline.
20use 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    // Parse every line up front. A tool_use block (assistant record) and its
54    // tool_result (the *following* human record) are correlated only by
55    // tool_use_id, so we need the whole document before emitting ToolCall events.
56    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            // Graceful degradation: record the parse error, keep going.
67            Err(e) => events.push(IngestEvent::SystemInstruction {
68                text: format!("[parse error on line {line_no}: {e}]"),
69            }),
70        }
71    }
72
73    // Pass 1: build tool_use_id -> result-text from tool_result blocks. This is
74    // where Read results (file contents) and Edit/Write confirmations live.
75    let mut results: HashMap<String, String> = HashMap::new();
76    for record in &records {
77        collect_tool_results(record, &mut results);
78    }
79
80    // The agent's stated reason for the tool calls that follow it. Real sessions
81    // split each `thinking` / `text` / `tool_use` block into its *own* assistant
82    // record, so rationale must be tracked across records (in document order),
83    // not within a single message. Reset at each new user turn so one turn's
84    // rationale never bleeds into the next.
85    let mut rationale: Option<String> = None;
86
87    // Pass 2: emit events in document order.
88    for record in &records {
89        // Extract timestamp from the record (top-level "timestamp" field).
90        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        // Track the earliest timestamp as session start.
98        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                // System prompt — contains CLAUDE.md or other instructions.
107                if let Some(text) = extract_text_from_record(record) {
108                    events.push(IngestEvent::SystemInstruction { text });
109                }
110            }
111            "summary" => {
112                // Claude Code compresses old context into a summary record.
113                // Treat the summary as a SystemInstruction so constraints in it are captured.
114                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                // Human / user turn.  May contain a user text message or tool results.
122                let message = match record.get("message") {
123                    Some(m) => m,
124                    None => continue,
125                };
126
127                let content = message.get("content");
128
129                // Check for plain-text user messages.
130                let text = extract_user_text(content);
131
132                if let Some(text) = text {
133                    // Skip slash-command invocations (e.g. "/autoplan …", "/clear").
134                    if text.starts_with('/') {
135                        continue;
136                    }
137                    // Skip very short boilerplate ("continue", "y", "ok", etc.).
138                    if is_boilerplate(&text) {
139                        continue;
140                    }
141                    // Skip multiple-choice answers ("A", "all B", …). These are
142                    // real replies to a question the agent asked *within* a task,
143                    // not new intents — so let the tool calls that follow roll up
144                    // to the prior substantive request instead of "A".
145                    if is_option_answer(&text) {
146                        continue;
147                    }
148                    // Skip auto-generated "user" turns (context-compaction recaps,
149                    // local-command output) — these are not the developer's intent
150                    // and otherwise pollute `vasari why` with multi-paragraph noise.
151                    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                        // Set the session source label from the first substantive message.
164                        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                    // A new turn begins — the agent hasn't stated a reason yet.
174                    rationale = None;
175
176                    events.push(IngestEvent::UserPrompt {
177                        text,
178                        timestamp: ts,
179                    });
180                }
181            }
182            "assistant" => {
183                // Assistant turn — look for tool_use blocks.
184                let message = match record.get("message") {
185                    Some(m) => m,
186                    None => continue,
187                };
188
189                // Claude Code records the agent's working directory per record;
190                // tool `file_path`s are absolute, so relativize against it.
191                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                    // The agent narrates its reason in a `text` (or `thinking`)
196                    // block, then issues the `tool_use` — usually in the *next*
197                    // record. Carry the most recent narration forward as the
198                    // rationale for the calls that follow it.
199                    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                // Unknown record type — skip.
232            }
233        }
234    }
235
236    // Prepend SessionStart now that we know the source label and start time.
237    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
244/// Extract a plain-text string from a user message content field.
245/// Content can be a bare string or an array of blocks.
246fn 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        // Collect all text blocks; skip tool_result blocks.
254        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                    // Tool results are not user intent — skip.
268                }
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
285/// Make an absolute tool `file_path` repo-relative by stripping the agent's
286/// working directory. Already-relative paths pass through unchanged.
287fn 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    // Only ingest tools we understand.
307    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    // Tool `file_path`s are absolute in real sessions; relativize against the
318    // record's cwd so they match the repo-relative paths `vasari why` queries.
319    if let Some(path) = args.get("file_path").and_then(|v| v.as_str()) {
320        let rel = relativize(path, cwd);
321        // For tools with paths, validate the path doesn't escape the repo
322        // (shared rule — see ingest::is_safe_path).
323        if !crate::ingest::is_safe_path(&rel) {
324            return None;
325        }
326        args["file_path"] = Value::String(rel);
327    }
328
329    // Correlate this tool_use with its tool_result (keyed by id). For Read this
330    // is the file content; for Edit/Write it is the success confirmation. Empty
331    // when the session has no matching result (e.g. truncated export).
332    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
348/// Collect tool_result blocks from a human record into `out` (tool_use_id -> text).
349/// Claude Code attaches each tool's result to the *following* human turn as a
350/// `tool_result` block keyed by `tool_use_id`.
351fn 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
376/// Extract the textual payload of a tool_result `content` field, which may be a
377/// bare string or an array of `{type:"text", text:"..."}` blocks.
378fn 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
408/// Very short single-word responses that don't carry intent.
409fn 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
435/// Auto-generated "user" turns that aren't the developer's intent: context
436/// compaction recaps and local-command output that Claude Code injects as user
437/// messages. Treating these as intents fills `vasari why` with multi-paragraph
438/// summaries instead of the actual request.
439fn 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        // Invoking a `/skill` injects the skill's SKILL.md as a user message.
444        "Base directory for this skill:",
445    ];
446    let trimmed = text.trim_start();
447    PREFIXES.iter().any(|p| trimmed.starts_with(p))
448        // Injected wrapper tags: slash-command expansion (`<command-message>` /
449        // `<command-name>` / `<local-command-stdout>`) and the harness system
450        // prompt (`<system_instruction>` / `<system-reminder>`) — none are the
451        // developer's intent.
452        || (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
459/// A multiple-choice answer to a question the agent asked mid-task (e.g. "A",
460/// "all B", "none of the above"). These are genuine user input but continue the
461/// prior request rather than opening a new intent, so — like [`is_boilerplate`] —
462/// they should not become an Intent of their own.
463fn is_option_answer(text: &str) -> bool {
464    let lower = text.trim().to_lowercase();
465    // A single option letter: "a", "b", … (an MC pick, not an instruction).
466    if lower.len() == 1 && lower.as_bytes()[0].is_ascii_lowercase() {
467        return true;
468    }
469    // "all", "all a", "all of the above", "none[ of the above]", "both", "neither".
470    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        // Trailing slash on cwd is tolerated.
490        assert_eq!(
491            relativize("/Users/dev/repo/src/auth.rs", Some("/Users/dev/repo/")),
492            "src/auth.rs"
493        );
494        // Already-relative paths pass through.
495        assert_eq!(
496            relativize("src/auth.rs", Some("/Users/dev/repo")),
497            "src/auth.rs"
498        );
499        // Path outside cwd is left untouched (is_safe_path rejects it later).
500        assert_eq!(
501            relativize("/etc/passwd", Some("/Users/dev/repo")),
502            "/etc/passwd"
503        );
504        // No cwd → unchanged.
505        assert_eq!(relativize("src/auth.rs", None), "src/auth.rs");
506    }
507
508    #[test]
509    fn parses_user_type_records_with_absolute_paths() {
510        // Real Claude Code sessions use `type:"user"` (not `"human"`) and record
511        // absolute file_paths under a per-record `cwd`. Both must be handled.
512        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        // The user prompt becomes an intent-bearing UserPrompt.
516        assert!(events
517            .iter()
518            .any(|e| matches!(e, IngestEvent::UserPrompt { text, .. } if text.contains("JWT"))));
519        // The Edit survives and its path is repo-relative.
520        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        // Context-compaction recaps and command-output caveats are injected as
533        // `user` records but are not the developer's intent.
534        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        // A genuine request is not synthetic.
544        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        // The text block immediately before a tool_use is the agent's stated
567        // reason for the call.
568        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        // Read's result (file content) arrives in the *next* human record,
584        // keyed by tool_use_id. The adapter must thread it onto the ToolCall.
585        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        // No matching tool_result (e.g. truncated export) → empty, not a panic.
603        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        // The agent asks a question mid-task; the user answers "A". The edit that
723        // follows must attribute to the real request, not to "A".
724        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        // A real message that merely starts with a known letter/word is kept.
756        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        // Invoking a /skill injects the skill's SKILL.md as a user message.
765        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        // Should not error; malformed line becomes a SystemInstruction with parse error note
816        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        // A human turn containing only tool_result blocks should not produce a UserPrompt
838        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}