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;
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),
Err(e) => events.push(IngestEvent::SystemInstruction {
text: format!("[parse error on line {line_no}: {e}]"),
}),
}
}
let mut results: HashMap<String, String> = HashMap::new();
for record in &records {
collect_tool_results(record, &mut results);
}
let mut rationale: Option<String> = None;
for record in &records {
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);
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" => {
if let Some(text) = extract_text_from_record(record) {
events.push(IngestEvent::SystemInstruction { text });
}
}
"summary" => {
if let Some(text) = record.get("summary").and_then(|v| v.as_str()) {
events.push(IngestEvent::SystemInstruction {
text: text.to_string(),
});
}
}
"human" | "user" => {
let message = match record.get("message") {
Some(m) => m,
None => continue,
};
let content = message.get("content");
let text = extract_user_text(content);
if let Some(text) = text {
if text.starts_with('/') {
continue;
}
if is_boilerplate(&text) {
continue;
}
if is_option_answer(&text) {
continue;
}
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 {
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;
}
rationale = None;
events.push(IngestEvent::UserPrompt {
text,
timestamp: ts,
});
}
}
"assistant" => {
let message = match record.get("message") {
Some(m) => m,
None => continue,
};
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()) {
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);
}
}
_ => {}
}
}
}
}
_ => {
}
}
}
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)
}
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() {
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" => {
}
_ => {}
}
}
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"))
}
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();
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()));
if let Some(path) = args.get("file_path").and_then(|v| v.as_str()) {
let rel = relativize(path, cwd);
if !crate::ingest::is_safe_path(&rel) {
return None;
}
args["file_path"] = Value::String(rel);
}
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),
})
}
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);
}
}
}
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
}
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())
}
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",
"Base directory for this skill:",
];
let trimmed = text.trim_start();
PREFIXES.iter().any(|p| trimmed.starts_with(p))
|| (trimmed.starts_with('<')
&& ["<command-message>", "<command-name>", "<local-command-stdout>",
"<system_instruction>", "<system-reminder>"]
.iter()
.any(|t| trimmed.starts_with(t)))
}
fn is_option_answer(text: &str) -> bool {
let lower = text.trim().to_lowercase();
if lower.len() == 1 && lower.as_bytes()[0].is_ascii_lowercase() {
return true;
}
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"
);
assert_eq!(
relativize("/Users/dev/repo/src/auth.rs", Some("/Users/dev/repo/")),
"src/auth.rs"
);
assert_eq!(
relativize("src/auth.rs", Some("/Users/dev/repo")),
"src/auth.rs"
);
assert_eq!(
relativize("/etc/passwd", Some("/Users/dev/repo")),
"/etc/passwd"
);
assert_eq!(relativize("src/auth.rs", None), "src/auth.rs");
}
#[test]
fn parses_user_type_records_with_absolute_paths() {
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();
assert!(events
.iter()
.any(|e| matches!(e, IngestEvent::UserPrompt { text, .. } if text.contains("JWT"))));
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() {
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>"
));
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() {
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() {
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() {
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() {
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:?}");
}
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() {
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();
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() {
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");
}
}