use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde_json::{json, Value};
use crate::models::{
ContentPartAuthorship, ContentPartOrigin, FileEdit, MessageAuthorship, MessageContentPart,
MessageCorrelationAuthority, ParsedSession, Provider, SessionRecord, SourceFile,
};
use crate::providers::{walk_roots, ProviderDiscovery};
use crate::util::{
find_repo_root, format_transcript_line, minimal_record, normalize_path, observe_session_end,
observe_session_start, parse_datetime, preview_from_text, substantive_text,
truncate_for_display, RawMessage,
};
pub struct AntigravityAdapter {
roots: Vec<PathBuf>,
}
impl AntigravityAdapter {
pub fn new(roots: Vec<PathBuf>) -> Self {
Self { roots }
}
pub fn discover(&self) -> Vec<SourceFile> {
self.discover_with_warnings().sources
}
pub(crate) fn discover_with_warnings(&self) -> ProviderDiscovery {
let mut files = Vec::<SourceFile>::new();
let mut indexes_by_log_dir = HashMap::<PathBuf, usize>::new();
let walked = walk_roots(&self.roots, None);
for entry in walked.entries {
let path = &entry.path;
if antigravity_transcript_priority(path) == 0 {
continue;
}
let mtime_ns = entry
.metadata
.modified()
.ok()
.and_then(|value| value.duration_since(std::time::UNIX_EPOCH).ok())
.map(|value| value.as_nanos() as i64)
.unwrap_or_default();
let source = SourceFile {
provider: Provider::Antigravity,
path: entry.path.clone(),
mtime_ns,
size_bytes: entry.metadata.len() as i64,
};
let log_dir = path.parent().unwrap_or(path).to_path_buf();
let existing_index = indexes_by_log_dir.get(&log_dir).copied();
let replace = existing_index
.map(|index| {
let existing = &files[index];
antigravity_transcript_priority(&existing.path)
< antigravity_transcript_priority(&source.path)
})
.unwrap_or(true);
if replace {
if let Some(index) = existing_index {
files[index] = source;
} else {
indexes_by_log_dir.insert(log_dir, files.len());
files.push(source);
}
}
}
ProviderDiscovery {
sources: files,
warnings: walked.warnings,
}
}
pub fn parse(&self, source: &SourceFile) -> ParsedSession {
match self.parse_inner(&source.path) {
Ok(parsed) => parsed,
Err(err) => minimal_record(Provider::Antigravity, &source.path, format!("{err:#}")),
}
}
fn parse_inner(&self, path: &Path) -> Result<ParsedSession> {
let file = std::fs::File::open(path)?;
self.parse_reader(std::io::BufReader::new(file), path)
}
pub fn parse_reader<R: std::io::BufRead>(
&self,
reader: R,
path: &Path,
) -> Result<ParsedSession> {
let mut line_count: usize = 0;
let mut malformed_line_count: usize = 0;
let mut valid_record_count: usize = 0;
let provider_session_id = path
.parent() .and_then(|p| p.parent()) .and_then(|p| p.parent()) .and_then(|p| p.file_name())
.and_then(|name| name.to_str())
.unwrap_or("unknown")
.to_string();
let mut cwd = None;
let mut created_at: Option<DateTime<Utc>> = None;
let mut updated_at: Option<DateTime<Utc>> = None;
let mut messages = Vec::new();
let mut transcript_lines = Vec::new();
let mut last_prompt = None;
let mut file_edits: Vec<FileEdit> = Vec::new();
let mut file_edit_seq: i64 = 0;
for line in crate::util::lines_replacing_invalid_utf8(reader) {
let line = line?;
line_count += 1;
let line = line.trim();
if line.is_empty() {
continue;
}
let value: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => {
malformed_line_count += 1;
continue;
}
};
valid_record_count += 1;
let timestamp = value
.get("created_at")
.and_then(Value::as_str)
.and_then(parse_datetime);
observe_session_start(&mut created_at, timestamp);
observe_session_end(&mut updated_at, timestamp);
if cwd.is_none() {
if let Some(tool_calls) = value.get("tool_calls").and_then(Value::as_array) {
for call in tool_calls {
if let Some(args) = call.get("args") {
if let Some(c) = args.get("Cwd").and_then(Value::as_str) {
cwd = Some(c.to_string());
break;
}
if let Some(c) = args.get("cwd").and_then(Value::as_str) {
cwd = Some(c.to_string());
break;
}
}
}
}
}
collect_antigravity_file_edits(&value, timestamp, &mut file_edit_seq, &mut file_edits);
let record_type = value.get("type").and_then(Value::as_str).unwrap_or("");
let source = value.get("source").and_then(Value::as_str).unwrap_or("");
let text = value.get("content").and_then(Value::as_str).unwrap_or("");
if !text.trim().is_empty() {
if let Some((role, tool_name)) = classify_antigravity_record(record_type, source) {
let text = text.trim().to_string();
if role == "user" && substantive_text(&text) {
last_prompt = Some(text.clone());
}
let mut raw_message = if role == "tool" {
RawMessage::tool_result_with_name(tool_name, text.clone(), None, timestamp)
} else {
RawMessage::message(role, text.clone(), timestamp, None)
};
if role == "user" && source == "USER_EXPLICIT" {
raw_message = match antigravity_user_request_parts(&text) {
Some(parts) => raw_message.with_content_parts(parts),
None => raw_message.with_authorship(MessageAuthorship::Human),
};
}
if let Some(step_index) = value.get("step_index").and_then(Value::as_i64) {
raw_message = raw_message.with_native_event_identity(
MessageCorrelationAuthority::Google,
step_index.to_string(),
);
}
messages.push(raw_message);
if role != "tool" {
transcript_lines.push(format_transcript_line(role, timestamp, &text));
}
}
}
append_antigravity_tool_call_messages(&value, timestamp, &mut messages);
}
let first_user = messages
.iter()
.find(|message| message.role() == "user" && substantive_text(message.content()))
.map(|message| message.content().to_string());
let last_user = messages
.iter()
.rev()
.find(|message| message.role() == "user" && substantive_text(message.content()))
.map(|message| message.content().to_string());
let title = last_prompt
.clone()
.or_else(|| last_user.clone())
.or_else(|| first_user.clone())
.clone()
.map(|text| truncate_for_display(&text, 100));
let preview = last_prompt
.clone()
.or_else(|| last_user.clone())
.or_else(|| first_user.clone())
.map(|text| preview_from_text(&text))
.unwrap_or_else(|| "(no preview available)".to_string());
let repo_root = cwd.as_deref().and_then(find_repo_root);
let mut raw_metadata = json!({
"line_count": line_count,
"valid_record_count": valid_record_count,
"session_path": normalize_path(path),
});
if malformed_line_count > 0 {
raw_metadata["malformed_line_count"] = json!(malformed_line_count);
}
let raw_metadata_json = Some(serde_json::to_string(&raw_metadata)?);
let session = SessionRecord {
id: format!("antigravity:{provider_session_id}"),
provider: Provider::Antigravity,
provider_session_id,
title,
summary: first_user.map(|text| truncate_for_display(&text, 180)),
cwd,
repo_root,
created_at,
updated_at,
last_message_at: updated_at,
preview_text: preview,
source_path: normalize_path(path),
message_count: Some(messages.len() as i64),
parse_version: crate::util::provider_parse_version(Provider::Antigravity).to_string(),
raw_metadata_json,
parse_warning: super::malformed_jsonl_warning(malformed_line_count),
discovery_source: "jsonl".to_string(),
parent_session_id: None,
agent_label: None,
};
let messages = crate::util::to_messages_with_tools_in_scope(messages, &session.id);
Ok(ParsedSession {
session,
transcript_text: transcript_lines.join("\n\n"),
messages,
file_edits,
})
}
}
fn antigravity_user_request_parts(content: &str) -> Option<Vec<MessageContentPart>> {
const OPEN: &str = "<USER_REQUEST>\n";
const CLOSE: &str = "\n</USER_REQUEST>";
let direct_input = content.strip_prefix(OPEN)?.strip_suffix(CLOSE)?;
if direct_input.is_empty() {
return None;
}
let open_end = OPEN.chars().count();
let input_end = open_end + direct_input.chars().count();
let content_end = content.chars().count();
Some(vec![
MessageContentPart {
ordinal: 0,
start_char: 0,
end_char: open_end,
authorship: ContentPartAuthorship::Harness,
origin: ContentPartOrigin::HarnessContext,
},
MessageContentPart {
ordinal: 1,
start_char: open_end,
end_char: input_end,
authorship: ContentPartAuthorship::Human,
origin: ContentPartOrigin::DirectInput,
},
MessageContentPart {
ordinal: 2,
start_char: input_end,
end_char: content_end,
authorship: ContentPartAuthorship::Harness,
origin: ContentPartOrigin::HarnessContext,
},
])
}
fn antigravity_transcript_priority(path: &Path) -> u8 {
match path.file_name().and_then(|name| name.to_str()) {
Some("transcript_full.jsonl") => 2,
Some("transcript.jsonl") => 1,
_ => 0,
}
}
fn classify_antigravity_record(
record_type: &str,
source: &str,
) -> Option<(&'static str, Option<String>)> {
match record_type {
"USER_INPUT" => Some(("user", None)),
"PLANNER_RESPONSE" => Some(("assistant", None)),
"CONVERSATION_HISTORY" => None,
"" => match source {
"USER_EXPLICIT" | "USER" => Some(("user", None)),
"MODEL" => Some(("assistant", None)),
_ => None,
},
other => match source {
"USER_EXPLICIT" | "USER" => Some(("user", None)),
_ => Some(("tool", Some(other.to_ascii_lowercase()))),
},
}
}
fn collect_antigravity_file_edits(
record: &Value,
ts: Option<DateTime<Utc>>,
next_seq: &mut i64,
out: &mut Vec<FileEdit>,
) {
let Some(tool_calls) = record.get("tool_calls").and_then(Value::as_array) else {
return;
};
for call in tool_calls {
let name = call.get("name").and_then(Value::as_str).unwrap_or_default();
if !matches!(
name,
"write_to_file" | "replace_file_content" | "multi_replace_file_content"
) {
continue;
}
let Some(args) = call.get("args") else {
continue;
};
let Some(file_path) = ["TargetFile", "file_path", "path"]
.iter()
.find_map(|key| args.get(*key).and_then(Value::as_str))
.map(str::to_string)
else {
continue;
};
let file_name = crate::util::file_basename(&file_path);
out.push(FileEdit {
seq: *next_seq,
ts,
tool: name.to_string(),
file_path,
file_name,
new_content: None,
edits: Vec::new(),
});
*next_seq += 1;
}
}
fn append_antigravity_tool_call_messages(
record: &Value,
ts: Option<DateTime<Utc>>,
out: &mut Vec<RawMessage>,
) {
let Some(tool_calls) = record.get("tool_calls").and_then(Value::as_array) else {
return;
};
for call in tool_calls {
let Some(name) = call.get("name").and_then(Value::as_str) else {
continue;
};
let args = call.get("args").cloned().unwrap_or(Value::Null);
out.push(RawMessage::tool_call(
&name.to_ascii_lowercase(),
args,
None,
ts,
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_antigravity_parser() {
let dir = tempdir().unwrap();
let session_dir = dir
.path()
.join("94fc19cc-ad62-42eb-aef9-c43deed34236/.system_generated/logs");
fs::create_dir_all(&session_dir).unwrap();
let log_file = session_dir.join("transcript.jsonl");
let log_content = r#"
{"step_index":1,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","created_at":"2026-05-19T23:13:00Z","content":"hello agent"}
{"step_index":2,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-05-19T23:13:05Z","content":"hello user","tool_calls":[{"name":"run_command","args":{"Cwd":"/path/to/repo"}}]}
"#;
fs::write(&log_file, log_content.trim()).unwrap();
let adapter = AntigravityAdapter::new(vec![dir.path().to_path_buf()]);
let files = adapter.discover();
assert_eq!(files.len(), 1);
let parsed = adapter.parse(&files[0]);
assert_eq!(
parsed.session.provider_session_id,
"94fc19cc-ad62-42eb-aef9-c43deed34236"
);
assert_eq!(
parsed.session.id,
"antigravity:94fc19cc-ad62-42eb-aef9-c43deed34236"
);
assert_eq!(parsed.session.cwd.as_deref(), Some("/path/to/repo"));
assert_eq!(parsed.session.message_count, Some(3));
assert!(parsed.transcript_text.contains("hello agent"));
assert!(parsed.transcript_text.contains("hello user"));
let tool_call = parsed
.messages
.iter()
.find(|message| message.tool_name.as_deref() == Some("run_command"))
.expect("tool_call input indexed as a tool message");
assert!(tool_call.content.contains(r#""kind":"tool_call""#));
assert!(tool_call.content.contains(r#""Cwd":"/path/to/repo""#));
assert_eq!(
parsed.messages[0].provenance.authorship,
crate::models::MessageAuthorship::Human
);
assert_eq!(
parsed.messages[0]
.provenance
.correlation_identity
.as_ref()
.map(|identity| identity.id.as_str()),
Some("1")
);
assert!(
tool_call.provenance.correlation_identity.is_none(),
"derived tool-call row must not reuse the enclosing planner step identity"
);
}
#[test]
fn discovers_antigravity_cli_brain_transcripts() {
let dir = tempdir().unwrap();
let root = dir.path().join(".gemini/antigravity-cli/brain");
let session_dir = root.join("5976941f-b237-4440-a02c-39593889400c/.system_generated/logs");
fs::create_dir_all(&session_dir).unwrap();
let log_file = session_dir.join("transcript.jsonl");
let log_content = r#"
{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","created_at":"2026-06-30T06:19:23Z","content":"<USER_REQUEST>\ntesting antigravity\n</USER_REQUEST>"}
{"step_index":2,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-06-30T06:19:23Z","content":"I am going to inspect the guide.","tool_calls":[{"name":"view_file","args":{"AbsolutePath":"/Users/example/.gemini/antigravity-cli/builtin/skills/antigravity_guide/SKILL.md","toolSummary":"View Antigravity Guide SKILL.md"}}]}
{"step_index":3,"source":"MODEL","type":"VIEW_FILE","status":"DONE","created_at":"2026-06-30T06:19:24Z","content":"File Path: `file:///Users/example/.gemini/antigravity-cli/builtin/skills/antigravity_guide/SKILL.md`\nTotal Lines: 54"}
"#;
fs::write(&log_file, log_content.trim()).unwrap();
let adapter = AntigravityAdapter::new(vec![root]);
let files = adapter.discover();
assert_eq!(files.len(), 1);
let parsed = adapter.parse(&files[0]);
assert_eq!(
parsed.session.provider_session_id,
"5976941f-b237-4440-a02c-39593889400c"
);
assert_eq!(parsed.session.message_count, Some(4));
assert_eq!(parsed.messages[0].role.as_str(), "user");
assert!(parsed.messages[0].content.contains("testing antigravity"));
assert_eq!(
parsed.messages[0].provenance.authorship,
crate::models::MessageAuthorship::Mixed
);
assert_eq!(parsed.messages[0].provenance.content_parts.len(), 3);
parsed.messages[0]
.provenance
.validate(&parsed.messages[0].content)
.expect("wrapper parts exactly cover the persisted Unicode-scalar content");
assert_eq!(
parsed.messages[0].provenance.content_parts[1].authorship,
crate::models::ContentPartAuthorship::Human
);
let call = parsed
.messages
.iter()
.find(|message| {
message.tool_name.as_deref() == Some("view_file")
&& message.content.contains(r#""kind":"tool_call""#)
})
.expect("view_file tool_call input indexed as a tool message");
assert!(call.content.contains("AbsolutePath"));
assert!(call.content.contains("SKILL.md"));
let result = parsed
.messages
.iter()
.find(|message| {
message.tool_name.as_deref() == Some("view_file")
&& message.content.contains("Total Lines: 54")
})
.expect("VIEW_FILE result step indexed as a tool message");
assert!(result.content.contains("File Path:"));
}
#[test]
fn discover_prefers_full_transcript_when_both_exist() {
let dir = tempdir().unwrap();
let root = dir.path().join(".gemini/antigravity-cli/brain");
let session_dir = root.join("769b6413-3652-42c0-a7c2-3c427cc99264/.system_generated/logs");
fs::create_dir_all(&session_dir).unwrap();
fs::write(session_dir.join("transcript.jsonl"), "{}\n").unwrap();
fs::write(
session_dir.join("transcript_full.jsonl"),
r#"{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","created_at":"2026-06-30T06:49:05Z","content":"<USER_REQUEST>\nfull transcript\n</USER_REQUEST>"}"#,
)
.unwrap();
let adapter = AntigravityAdapter::new(vec![root]);
let files = adapter.discover();
assert_eq!(files.len(), 1);
assert_eq!(
files[0].path.file_name().and_then(|name| name.to_str()),
Some("transcript_full.jsonl")
);
let parsed = adapter.parse(&files[0]);
assert_eq!(
parsed.session.provider_session_id,
"769b6413-3652-42c0-a7c2-3c427cc99264"
);
assert!(parsed.transcript_text.contains("full transcript"));
}
#[test]
fn indexes_tool_step_records_as_tool_messages() {
use crate::models::Role;
let dir = tempdir().unwrap();
let session_dir = dir
.path()
.join("94fc19cc-ad62-42eb-aef9-c43deed34236/.system_generated/logs");
fs::create_dir_all(&session_dir).unwrap();
let log_file = session_dir.join("transcript.jsonl");
let log_content = r#"
{"step_index":0,"source":"MODEL","type":"CONVERSATION_HISTORY","status":"DONE","created_at":"2026-05-19T23:12:00Z","content":"earlier replayed turn"}
{"step_index":1,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","created_at":"2026-05-19T23:13:00Z","content":"run ls"}
{"step_index":2,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-05-19T23:13:02Z","content":"I'll run it","tool_calls":[{"name":"run_command","args":{"Cwd":"/repo"}}]}
{"step_index":3,"source":"MODEL","type":"RUN_COMMAND","status":"DONE","created_at":"2026-05-19T23:13:05Z","content":"file1.txt\nfile2.txt"}
"#;
fs::write(&log_file, log_content.trim()).unwrap();
let adapter = AntigravityAdapter::new(vec![dir.path().to_path_buf()]);
let parsed = adapter.parse(&adapter.discover()[0]);
assert_eq!(parsed.session.message_count, Some(4));
let tool = parsed
.messages
.iter()
.find(|m| m.role == Role::Tool && m.content == "file1.txt\nfile2.txt")
.expect("RUN_COMMAND indexed as a Role::Tool message");
assert_eq!(tool.tool_name.as_deref(), Some("run_command"));
assert_eq!(tool.kind, crate::models::MessageKind::ToolResult);
assert!(tool.tool_call_id.is_none());
let call = parsed
.messages
.iter()
.find(|m| {
m.role == Role::Tool
&& m.tool_name.as_deref() == Some("run_command")
&& m.content.contains(r#""kind":"tool_call""#)
})
.expect("RUN_COMMAND tool_call input indexed as a Role::Tool message");
assert!(call.content.contains(r#""Cwd":"/repo""#));
assert_eq!(call.kind, crate::models::MessageKind::ToolCall);
assert!(call.tool_call_id.is_none());
assert!(!parsed.transcript_text.contains("file1.txt"));
assert!(!parsed.transcript_text.contains("earlier replayed turn"));
assert!(!parsed
.messages
.iter()
.any(|m| m.content.contains("earlier replayed turn")));
}
#[test]
fn extracts_path_only_file_edits_from_tool_calls() {
let dir = tempdir().unwrap();
let session_dir = dir
.path()
.join("94fc19cc-ad62-42eb-aef9-c43deed34236/.system_generated/logs");
fs::create_dir_all(&session_dir).unwrap();
let log_file = session_dir.join("transcript.jsonl");
let log_content = r#"
{"step_index":1,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","created_at":"2026-05-19T23:13:00Z","content":"edit files"}
{"step_index":2,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-05-19T23:13:02Z","content":"editing","tool_calls":[{"name":"write_to_file","args":{"TargetFile":"/repo/src/main.rs","CodeContent":"fn main(){}"}},{"name":"replace_file_content","args":{"TargetFile":"/repo/src/lib.rs"}},{"name":"run_command","args":{"CommandLine":"cargo build"}}]}
"#;
fs::write(&log_file, log_content.trim()).unwrap();
let adapter = AntigravityAdapter::new(vec![dir.path().to_path_buf()]);
let parsed = adapter.parse(&adapter.discover()[0]);
assert_eq!(parsed.file_edits.len(), 2, "{:?}", parsed.file_edits);
let names: Vec<&str> = parsed
.file_edits
.iter()
.map(|e| e.file_name.as_str())
.collect();
assert!(names.contains(&"main.rs"), "{names:?}");
assert!(names.contains(&"lib.rs"), "{names:?}");
assert!(parsed
.file_edits
.iter()
.all(|e| e.new_content.is_none() && e.edits.is_empty()));
let main = parsed
.file_edits
.iter()
.find(|e| e.file_name == "main.rs")
.unwrap();
assert_eq!(main.tool, "write_to_file");
assert_eq!(main.file_path, "/repo/src/main.rs");
}
#[test]
fn streaming_parse_output_is_stable() {
use crate::models::Role;
let dir = tempdir().unwrap();
let session_dir = dir
.path()
.join("94fc19cc-ad62-42eb-aef9-c43deed34236/.system_generated/logs");
fs::create_dir_all(&session_dir).unwrap();
let log_file = session_dir.join("transcript.jsonl");
let content = concat!(
"\n",
r#"{"step_index":1,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","created_at":"2026-05-19T23:13:00Z","content":"hello agent"}"#,
"\n",
"{bad json\n",
r#"{"step_index":2,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-05-19T23:13:05Z","content":"hello user","tool_calls":[{"name":"run_command","args":{"Cwd":"/path/to/repo"}}]}"#,
);
fs::write(&log_file, content).unwrap();
let adapter = AntigravityAdapter::new(vec![dir.path().to_path_buf()]);
let parsed = adapter.parse(&adapter.discover()[0]);
assert!(
parsed
.session
.raw_metadata_json
.as_deref()
.unwrap()
.contains("\"line_count\":4"),
"line_count must be 4, got: {:?}",
parsed.session.raw_metadata_json
);
assert_eq!(parsed.session.cwd.as_deref(), Some("/path/to/repo"));
let contents: Vec<&str> = parsed.messages.iter().map(|m| m.content.as_str()).collect();
assert_eq!(
contents,
vec![
"hello agent",
"hello user",
r#"{"args":{"Cwd":"/path/to/repo"},"kind":"tool_call","tool_name":"run_command"}"#
]
);
let roles: Vec<Role> = parsed.messages.iter().map(|m| m.role).collect();
assert_eq!(roles, vec![Role::User, Role::Assistant, Role::Tool]);
assert!(parsed.transcript_text.contains("hello agent"));
assert!(parsed.transcript_text.contains("hello user"));
assert_eq!(
parsed.session.parse_warning.as_deref(),
Some("skipped 1 malformed JSONL record")
);
}
#[test]
fn non_utf8_garbage_is_diagnosed_without_aborting_the_parse() {
let dir = tempdir().unwrap();
let session_dir = dir
.path()
.join("94fc19cc-ad62-42eb-aef9-c43deed34236/.system_generated/logs");
fs::create_dir_all(&session_dir).unwrap();
let log_file = session_dir.join("transcript.jsonl");
fs::write(&log_file, [b'{', 0xFF, 0xFE, b'}', b'\n']).unwrap();
let adapter = AntigravityAdapter::new(vec![dir.path().to_path_buf()]);
let parsed = adapter.parse(&adapter.discover()[0]);
assert!(parsed.messages.is_empty());
assert_eq!(
parsed.session.parse_warning.as_deref(),
Some("skipped 1 malformed JSONL record")
);
assert_eq!(parsed.session.message_count, Some(0));
}
}