use super::models::{ContentBlock, ExecutionNode, MessageContent, NodeType, Session, TokenUsage};
use crate::error::{HindsightError, Result};
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
pub fn parse_subagents(session_path: &Path) -> Vec<super::models::Session> {
let session_id = session_path.file_stem().unwrap_or_default().to_string_lossy();
let subagent_dir = session_path
.parent()
.map(|p| p.join(session_id.as_ref()).join("subagents"));
let Some(dir) = subagent_dir else {
return vec![];
};
if !dir.exists() {
return vec![];
}
std::fs::read_dir(&dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "jsonl"))
.filter_map(|e| parse_session(&e.path()).ok())
.collect()
}
pub fn parse_session(path: &Path) -> Result<Session> {
let file = File::open(path)?;
let file_metadata = file.metadata()?;
let file_size = file_metadata.len();
let estimated_lines = (file_size / 500).max(100) as usize;
let reader = BufReader::new(file);
let mut raw_nodes = Vec::with_capacity(estimated_lines);
for (line_num, line_result) in reader.lines().enumerate() {
let line = line_result?;
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<ExecutionNode>(&line) {
Ok(node) => {
raw_nodes.push(node);
}
Err(e) => {
return Err(HindsightError::JsonParse {
line: line_num + 1,
message: e.to_string(),
});
}
}
}
let mut merged: Vec<ExecutionNode> = Vec::with_capacity(raw_nodes.len());
let mut current_id: Option<String> = None;
let mut current_base: Option<ExecutionNode> = None;
let mut current_content: Vec<ContentBlock> = Vec::new();
let mut current_usage: Option<TokenUsage> = None;
for node in raw_nodes {
match extract_message_id(&node) {
Some(id) if current_id.as_deref() == Some(id) => {
let new_blocks = extract_blocks(&node);
if !new_blocks.is_empty() {
current_content.extend(new_blocks);
}
if let Some(tu) = node.effective_token_usage() {
match current_usage.as_mut() {
Some(existing) => existing.merge_last(tu),
None => current_usage = Some(tu.clone()),
}
}
}
Some(id) => {
if let Some(base) = current_base.take() {
merged.push(finalize_sse(base, current_content, current_usage));
}
current_id = Some(id.to_string());
current_content = extract_blocks(&node);
current_usage = node.effective_token_usage().cloned();
current_base = Some(node);
}
None => {
if let Some(base) = current_base.take() {
merged.push(finalize_sse(base, current_content, current_usage));
current_id = None;
current_content = Vec::new();
current_usage = None;
}
merged.push(node);
}
}
}
if let Some(base) = current_base.take() {
merged.push(finalize_sse(base, current_content, current_usage));
}
let merged = merged;
let nodes = dedup_progress_by_tool_use_id(merged);
let session_id = extract_session_id(path)?;
let file_path = path
.canonicalize()
.ok()
.and_then(|p| p.to_str().map(String::from))
.or_else(|| path.to_str().map(String::from));
Ok(Session::new(session_id, file_path, nodes))
}
fn extract_session_id(path: &Path) -> Result<String> {
if let Some(file_name) = path.file_stem() {
if let Some(name) = file_name.to_str() {
return Ok(name.to_string());
}
}
Err(HindsightError::InvalidSession(
"Could not extract session ID from path".to_string(),
))
}
fn extract_message_id(node: &ExecutionNode) -> Option<&str> {
node.message.as_ref()?.id.as_deref()
}
fn extract_blocks(node: &ExecutionNode) -> Vec<ContentBlock> {
node.message
.as_ref()
.and_then(|m| m.content.as_ref())
.map(|c| match c {
MessageContent::Blocks(b) => b.clone(),
MessageContent::Text(_) => vec![],
})
.unwrap_or_default()
}
fn finalize_sse(
mut base: ExecutionNode,
content: Vec<ContentBlock>,
token_usage: Option<TokenUsage>,
) -> ExecutionNode {
if let Some(ref mut msg) = base.message {
if !content.is_empty() {
msg.content = Some(MessageContent::Blocks(content));
}
}
base.token_usage = token_usage;
base
}
fn extract_tool_use_id(node: &ExecutionNode) -> Option<String> {
node.extra
.as_ref()
.and_then(|e| e.get("toolUseID"))
.and_then(|v| v.as_str())
.map(str::to_string)
}
fn dedup_progress_by_tool_use_id(nodes: Vec<ExecutionNode>) -> Vec<ExecutionNode> {
use std::collections::HashMap;
let mut last_idx: HashMap<String, usize> = HashMap::new();
for (i, node) in nodes.iter().enumerate() {
if node.node_type == NodeType::Progress {
if let Some(id) = extract_tool_use_id(node) {
last_idx.insert(id, i);
}
}
}
nodes
.into_iter()
.enumerate()
.filter(|(i, node)| {
if node.node_type == NodeType::Progress {
if let Some(id) = extract_tool_use_id(node) {
return last_idx.get(&id) == Some(i);
}
}
true
})
.map(|(_, node)| node)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::models::{Message, NodeType, TokenUsage};
use std::collections::HashMap;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_parse_empty_file() {
let file = NamedTempFile::new().unwrap();
let session = parse_session(file.path()).unwrap();
assert_eq!(session.nodes.len(), 0);
}
#[test]
fn test_parse_user_message() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, r#"{{"type":"user","message":{{"content":"Hello"}}}}"#).unwrap();
let session = parse_session(file.path()).unwrap();
assert_eq!(session.nodes.len(), 1);
assert_eq!(session.nodes[0].node_type, NodeType::User);
}
#[test]
fn test_parse_tool_use() {
let mut file = NamedTempFile::new().unwrap();
writeln!(
file,
r#"{{"type":"assistant","message":{{"role":"assistant","id":"msg-1","content":[{{"type":"tool_use","id":"tu-1","name":"Read","input":{{"file_path":"test.txt"}}}}]}}}}"#
)
.unwrap();
let session = parse_session(file.path()).unwrap();
assert_eq!(session.nodes.len(), 1);
let blocks = session.nodes[0].message.as_ref().unwrap().content_blocks();
assert_eq!(blocks.len(), 1);
assert!(matches!(blocks[0], super::super::models::ContentBlock::ToolUse { ref name, .. } if name == "Read"));
assert_eq!(session.total_tools, 1);
}
#[test]
fn test_invalid_json() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "{{invalid json").unwrap();
let result = parse_session(file.path());
assert!(result.is_err());
}
fn make_assistant_node(id: &str, text: &str, tokens_out: i64) -> ExecutionNode {
ExecutionNode {
uuid: Some(format!("uuid-{}", id)),
parent_uuid: None,
timestamp: Some(1000),
node_type: NodeType::Assistant,
is_sidechain: None,
session_id: None,
cwd: None,
message: Some(Message {
id: Some(id.to_string()),
role: Some("assistant".to_string()),
model: None,
content: Some(MessageContent::Blocks(vec![ContentBlock::Text {
text: text.to_string(),
}])),
usage: None,
extra: HashMap::new(),
}),
tool_use: None,
tool_result: None,
tool_use_result: None,
thinking: None,
progress: None,
token_usage: Some(TokenUsage {
input_tokens: Some(100),
output_tokens: Some(tokens_out),
cache_creation_input_tokens: None,
cache_read_input_tokens: None,
}),
extra: None,
}
}
fn make_tool_node() -> ExecutionNode {
ExecutionNode {
uuid: Some("uuid-tool".to_string()),
parent_uuid: None,
timestamp: Some(2000),
node_type: NodeType::Unknown,
is_sidechain: None,
session_id: None,
cwd: None,
message: None,
tool_use: None,
tool_result: None,
tool_use_result: None,
thinking: None,
progress: None,
token_usage: None,
extra: None,
}
}
#[test]
fn test_sse_deduplication_single_message_id_produces_one_node() {
let mut file = NamedTempFile::new().unwrap();
let node1 = make_assistant_node("msg-abc", "partial", 10);
let node2 = make_assistant_node("msg-abc", "full text here", 20);
writeln!(file, "{}", serde_json::to_string(&node1).unwrap()).unwrap();
writeln!(file, "{}", serde_json::to_string(&node2).unwrap()).unwrap();
let session = parse_session(file.path()).unwrap();
assert_eq!(session.nodes.len(), 1);
}
#[test]
fn test_sse_deduplication_two_message_ids_produce_two_nodes() {
let mut file = NamedTempFile::new().unwrap();
let node1 = make_assistant_node("msg-aaa", "first message", 10);
let node2 = make_assistant_node("msg-bbb", "second message", 20);
writeln!(file, "{}", serde_json::to_string(&node1).unwrap()).unwrap();
writeln!(file, "{}", serde_json::to_string(&node2).unwrap()).unwrap();
let session = parse_session(file.path()).unwrap();
assert_eq!(session.nodes.len(), 2);
}
#[test]
fn test_sse_deduplication_token_usage_takes_last_cumulative_value() {
let mut file = NamedTempFile::new().unwrap();
let node1 = make_assistant_node("msg-xyz", "partial", 10);
let node2 = make_assistant_node("msg-xyz", "complete response", 50);
writeln!(file, "{}", serde_json::to_string(&node1).unwrap()).unwrap();
writeln!(file, "{}", serde_json::to_string(&node2).unwrap()).unwrap();
let session = parse_session(file.path()).unwrap();
assert_eq!(session.nodes.len(), 1);
let usage = session.nodes[0].token_usage.as_ref().unwrap();
assert_eq!(usage.output_tokens, Some(50));
}
#[test]
fn test_sse_deduplication_non_assistant_nodes_pass_through_unchanged() {
let mut file = NamedTempFile::new().unwrap();
let tool = make_tool_node();
writeln!(file, "{}", serde_json::to_string(&tool).unwrap()).unwrap();
let session = parse_session(file.path()).unwrap();
assert_eq!(session.nodes.len(), 1);
assert_eq!(session.nodes[0].node_type, NodeType::Unknown);
}
fn make_progress_node(tool_use_id: &str, uuid: &str) -> ExecutionNode {
let mut extra = HashMap::new();
extra.insert("toolUseID".to_string(), serde_json::json!(tool_use_id));
extra.insert(
"data".to_string(),
serde_json::json!({
"type": "agent_progress",
"agentId": "abc123",
"prompt": "do something",
"message": {},
"normalizedMessages": []
}),
);
ExecutionNode {
uuid: Some(uuid.to_string()),
parent_uuid: None,
timestamp: Some(1000),
node_type: NodeType::Progress,
is_sidechain: None,
session_id: None,
cwd: None,
message: None,
tool_use: None,
tool_result: None,
tool_use_result: None,
thinking: None,
progress: None,
token_usage: None,
extra: Some(extra),
}
}
#[test]
fn test_progress_dedup_keeps_only_last_frame_per_tool_use_id() {
let mut file = NamedTempFile::new().unwrap();
let n1 = make_progress_node("tool-abc", "uuid-1");
let n2 = make_progress_node("tool-abc", "uuid-2");
let n3 = make_progress_node("tool-abc", "uuid-3");
writeln!(file, "{}", serde_json::to_string(&n1).unwrap()).unwrap();
writeln!(file, "{}", serde_json::to_string(&n2).unwrap()).unwrap();
writeln!(file, "{}", serde_json::to_string(&n3).unwrap()).unwrap();
let session = parse_session(file.path()).unwrap();
assert_eq!(session.nodes.len(), 1, "3 frames should collapse to 1");
assert_eq!(
session.nodes[0].uuid,
Some("uuid-3".to_string()),
"last frame kept"
);
}
#[test]
fn test_progress_dedup_preserves_distinct_tool_use_ids() {
let mut file = NamedTempFile::new().unwrap();
let a1 = make_progress_node("tool-A", "uuid-a1");
let a2 = make_progress_node("tool-A", "uuid-a2");
let b1 = make_progress_node("tool-B", "uuid-b1");
let b2 = make_progress_node("tool-B", "uuid-b2");
writeln!(file, "{}", serde_json::to_string(&a1).unwrap()).unwrap();
writeln!(file, "{}", serde_json::to_string(&a2).unwrap()).unwrap();
writeln!(file, "{}", serde_json::to_string(&b1).unwrap()).unwrap();
writeln!(file, "{}", serde_json::to_string(&b2).unwrap()).unwrap();
let session = parse_session(file.path()).unwrap();
assert_eq!(session.nodes.len(), 2, "two distinct tool IDs → 2 nodes");
assert_eq!(session.nodes[0].uuid, Some("uuid-a2".to_string()));
assert_eq!(session.nodes[1].uuid, Some("uuid-b2".to_string()));
}
fn write_jsonl_fixture(file: &mut impl std::io::Write, json: &str) {
let value: serde_json::Value = serde_json::from_str(json)
.expect("fixture JSON must be valid");
writeln!(file, "{}", value).unwrap();
}
#[test]
fn fixture_user_node_deserializes_parent_uuid() {
let json = r#"{
"parentUuid": "79b0d470-84e4-42ab-8c38-bcff7a0aa24a",
"isSidechain": false,
"userType": "external",
"cwd": "/home/user/project",
"sessionId": "a5134111-4445-460d-9848-3652e0364cc3",
"version": "2.1.71",
"type": "user",
"message": {
"role": "user",
"content": "Hello world"
},
"uuid": "c9f738fe-c3ee-4a41-a3da-a70ea43149f5",
"timestamp": "2026-03-09T20:36:13.828Z",
"permissionMode": "default"
}"#;
let mut file = NamedTempFile::new().unwrap();
write_jsonl_fixture(&mut file, json);
let session = parse_session(file.path()).unwrap();
assert_eq!(session.nodes.len(), 1);
let node = &session.nodes[0];
assert_eq!(node.node_type, NodeType::User);
assert_eq!(node.uuid.as_deref(), Some("c9f738fe-c3ee-4a41-a3da-a70ea43149f5"));
assert_eq!(
node.parent_uuid.as_deref(),
Some("79b0d470-84e4-42ab-8c38-bcff7a0aa24a"),
"parentUuid (camelCase) must deserialize into parent_uuid"
);
assert_eq!(node.is_sidechain, Some(false));
assert_eq!(node.session_id.as_deref(), Some("a5134111-4445-460d-9848-3652e0364cc3"));
assert_eq!(node.cwd.as_deref(), Some("/home/user/project"));
}
#[test]
fn fixture_assistant_node_with_thinking_and_tool_use() {
let json = r#"{
"parentUuid": "c9f738fe-c3ee-4a41-a3da-a70ea43149f5",
"isSidechain": false,
"sessionId": "a5134111-4445-460d-9848-3652e0364cc3",
"version": "2.1.71",
"message": {
"model": "claude-opus-4-6-20260101",
"id": "msg_01E7qN343ih6AF31ZohVDNC4",
"type": "message",
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "Let me plan this carefully.",
"signature": "sig_abc123"
},
{
"type": "tool_use",
"id": "toolu_01MXE8tThu2BW3FvknemYN26",
"name": "Bash",
"input": { "command": "ls -la" }
}
],
"stop_reason": "tool_use",
"usage": {
"input_tokens": 1500,
"output_tokens": 80,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 1200
}
},
"requestId": "req_abc",
"type": "assistant",
"uuid": "87eefa21-2674-4cd8-a892-7dd6c18a0f85",
"timestamp": "2026-03-09T20:36:22.317Z"
}"#;
let mut file = NamedTempFile::new().unwrap();
write_jsonl_fixture(&mut file, json);
let session = parse_session(file.path()).unwrap();
let node = &session.nodes[0];
assert_eq!(node.node_type, NodeType::Assistant);
assert_eq!(
node.parent_uuid.as_deref(),
Some("c9f738fe-c3ee-4a41-a3da-a70ea43149f5")
);
let msg = node.message.as_ref().unwrap();
assert_eq!(msg.model_short(), Some("claude-opus-4-6"));
assert_eq!(msg.id.as_deref(), Some("msg_01E7qN343ih6AF31ZohVDNC4"));
let blocks = msg.content_blocks();
assert_eq!(blocks.len(), 2);
assert!(matches!(blocks[0], crate::parser::models::ContentBlock::Thinking { .. }));
assert!(matches!(blocks[1], crate::parser::models::ContentBlock::ToolUse { ref name, .. } if name == "Bash"));
let usage = node.effective_token_usage().unwrap();
assert_eq!(usage.input_tokens, Some(1500));
assert_eq!(usage.output_tokens, Some(80));
assert_eq!(usage.cache_read_input_tokens, Some(1200));
assert_eq!(session.total_tools, 1);
}
#[test]
fn fixture_user_node_with_tool_result_block() {
let json = r#"{
"parentUuid": "87eefa21-2674-4cd8-a892-7dd6c18a0f85",
"isSidechain": false,
"sessionId": "a5134111-4445-460d-9848-3652e0364cc3",
"type": "user",
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01MXE8tThu2BW3FvknemYN26",
"content": [{ "type": "text", "text": "file1.rs\nfile2.rs" }]
}
]
},
"uuid": "f1b2c3d4-0000-0000-0000-000000000001",
"timestamp": "2026-03-09T20:36:23.000Z"
}"#;
let mut file = NamedTempFile::new().unwrap();
write_jsonl_fixture(&mut file, json);
let session = parse_session(file.path()).unwrap();
let node = &session.nodes[0];
assert_eq!(node.node_type, NodeType::User);
assert_eq!(
node.parent_uuid.as_deref(),
Some("87eefa21-2674-4cd8-a892-7dd6c18a0f85")
);
let blocks = node.message.as_ref().unwrap().content_blocks();
assert_eq!(blocks.len(), 1);
assert!(
matches!(blocks[0], crate::parser::models::ContentBlock::ToolResult { ref tool_use_id, .. }
if tool_use_id == "toolu_01MXE8tThu2BW3FvknemYN26")
);
}
#[test]
fn fixture_progress_bash_progress_data_accessible_via_extra() {
let json = r#"{
"parentUuid": "59880d07-e97a-462a-b93e-af460e5f8608",
"isSidechain": false,
"sessionId": "a5134111-4445-460d-9848-3652e0364cc3",
"type": "progress",
"data": {
"type": "bash_progress",
"output": "",
"fullOutput": "hello",
"elapsedTimeSeconds": 3,
"totalLines": 0,
"totalBytes": 0,
"taskId": "bso2475ff",
"timeoutMs": 120000
},
"toolUseID": "bash-progress-0",
"parentToolUseID": "toolu_01MXE8tThu2BW3FvknemYN26",
"uuid": "c5efb084-fd8d-46c2-9961-7d32017013fb",
"timestamp": "2026-03-09T20:43:55.501Z"
}"#;
let mut file = NamedTempFile::new().unwrap();
write_jsonl_fixture(&mut file, json);
let session = parse_session(file.path()).unwrap();
let node = &session.nodes[0];
assert_eq!(node.node_type, NodeType::Progress);
assert_eq!(node.parent_uuid.as_deref(), Some("59880d07-e97a-462a-b93e-af460e5f8608"));
let data = node.extra.as_ref()
.and_then(|e| e.get("data"))
.expect("progress data must be in extra[\"data\"]");
assert_eq!(data.get("type").and_then(|t| t.as_str()), Some("bash_progress"));
assert_eq!(data.get("elapsedTimeSeconds").and_then(|v| v.as_f64()), Some(3.0));
assert_eq!(data.get("fullOutput").and_then(|v| v.as_str()), Some("hello"));
}
#[test]
fn fixture_progress_agent_progress_data_accessible_via_extra() {
let json = r#"{
"parentUuid": "3df6921d-08eb-4ebd-b938-9ef91842a22a",
"isSidechain": false,
"sessionId": "a5134111-4445-460d-9848-3652e0364cc3",
"type": "progress",
"data": {
"type": "agent_progress",
"agentId": "agent-abc123",
"prompt": "Explore the codebase.",
"message": {}
},
"toolUseID": "toolu_agent_01",
"parentToolUseID": "toolu_agent_01",
"uuid": "1e3d2e80-2fed-4876-a3eb-6d049630107b",
"timestamp": "2026-03-09T20:36:22.317Z"
}"#;
let mut file = NamedTempFile::new().unwrap();
write_jsonl_fixture(&mut file, json);
let session = parse_session(file.path()).unwrap();
let node = &session.nodes[0];
assert_eq!(node.node_type, NodeType::Progress);
let data = node.extra.as_ref()
.and_then(|e| e.get("data"))
.expect("agent_progress data must be in extra[\"data\"]");
assert_eq!(data.get("type").and_then(|t| t.as_str()), Some("agent_progress"));
assert_eq!(data.get("agentId").and_then(|v| v.as_str()), Some("agent-abc123"));
}
#[test]
fn fixture_progress_hook_progress_data_accessible_via_extra() {
let json = r#"{
"parentUuid": null,
"isSidechain": false,
"sessionId": "a5134111-4445-460d-9848-3652e0364cc3",
"type": "progress",
"data": {
"type": "hook_progress",
"hookEvent": "SessionStart",
"hookName": "SessionStart:startup",
"command": "/usr/local/bin/claude-hindsight hook session-start"
},
"parentToolUseID": "4a0f7372-12b8-41fe-9ba0-adb589b649ac",
"toolUseID": "4a0f7372-12b8-41fe-9ba0-adb589b649ac",
"timestamp": "2026-03-09T20:32:19.071Z",
"uuid": "79b0d470-84e4-42ab-8c38-bcff7a0aa24a"
}"#;
let mut file = NamedTempFile::new().unwrap();
write_jsonl_fixture(&mut file, json);
let session = parse_session(file.path()).unwrap();
let node = &session.nodes[0];
assert_eq!(node.node_type, NodeType::Progress);
assert_eq!(node.parent_uuid, None);
let data = node.extra.as_ref()
.and_then(|e| e.get("data"))
.expect("hook_progress data must be in extra[\"data\"]");
assert_eq!(data.get("type").and_then(|t| t.as_str()), Some("hook_progress"));
assert_eq!(data.get("hookEvent").and_then(|v| v.as_str()), Some("SessionStart"));
}
#[test]
fn fixture_system_stop_hook_summary_parses() {
let json = r#"{
"parentUuid": "b566be18-5b56-472c-936b-bf8856c055b3",
"isSidechain": false,
"sessionId": "a5134111-4445-460d-9848-3652e0364cc3",
"slug": "bubbly-zooming-quokka",
"type": "system",
"subtype": "stop_hook_summary",
"hookCount": 1,
"hookInfos": [{ "command": "/usr/local/bin/claude-hindsight hook stop" }],
"hookErrors": [],
"preventedContinuation": false,
"stopReason": "",
"hasOutput": false,
"level": "suggestion",
"timestamp": "2026-03-09T20:44:20.015Z",
"uuid": "185d0e88-785f-413d-a825-ecbe6d44ce7e",
"toolUseID": "70eef7b4-fb90-4e73-a5b9-578eb55924f1"
}"#;
let mut file = NamedTempFile::new().unwrap();
write_jsonl_fixture(&mut file, json);
let session = parse_session(file.path()).unwrap();
let node = &session.nodes[0];
assert_eq!(node.node_type, NodeType::System);
assert_eq!(
node.parent_uuid.as_deref(),
Some("b566be18-5b56-472c-936b-bf8856c055b3")
);
let subtype = node.extra.as_ref()
.and_then(|e| e.get("subtype"))
.and_then(|v| v.as_str());
assert_eq!(subtype, Some("stop_hook_summary"));
}
#[test]
fn fixture_file_history_snapshot_parses() {
let json = r#"{
"type": "file-history-snapshot",
"messageId": "c9f738fe-c3ee-4a41-a3da-a70ea43149f5",
"snapshot": {
"messageId": "c9f738fe-c3ee-4a41-a3da-a70ea43149f5",
"trackedFileBackups": {},
"timestamp": "2026-03-09T20:36:13.828Z"
},
"isSnapshotUpdate": false
}"#;
let mut file = NamedTempFile::new().unwrap();
write_jsonl_fixture(&mut file, json);
let session = parse_session(file.path()).unwrap();
let node = &session.nodes[0];
assert_eq!(node.node_type, NodeType::FileHistorySnapshot);
assert_eq!(node.uuid, None);
assert_eq!(node.parent_uuid, None);
assert!(node.extra.as_ref().and_then(|e| e.get("snapshot")).is_some());
}
#[test]
fn fixture_last_prompt_node_parses() {
let json = r#"{
"type": "last-prompt",
"lastPrompt": "can you push it",
"sessionId": "a5134111-4445-460d-9848-3652e0364cc3"
}"#;
let mut file = NamedTempFile::new().unwrap();
write_jsonl_fixture(&mut file, json);
let session = parse_session(file.path()).unwrap();
let node = &session.nodes[0];
assert_eq!(node.node_type, NodeType::LastPrompt);
let prompt = node.extra.as_ref()
.and_then(|e| e.get("lastPrompt"))
.and_then(|v| v.as_str());
assert_eq!(prompt, Some("can you push it"));
}
#[test]
fn fixture_pr_link_node_parses() {
let json = r#"{
"type": "pr-link",
"sessionId": "48317b72-a9e4-4c5d-87a4-9f8a6f912e27",
"prNumber": 1,
"prUrl": "https://github.com/example/repo/pull/1",
"prRepository": "example/repo",
"timestamp": "2026-03-02T01:20:49.326Z"
}"#;
let mut file = NamedTempFile::new().unwrap();
write_jsonl_fixture(&mut file, json);
let session = parse_session(file.path()).unwrap();
let node = &session.nodes[0];
assert_eq!(node.node_type, NodeType::PrLink);
let pr_url = node.extra.as_ref()
.and_then(|e| e.get("prUrl"))
.and_then(|v| v.as_str());
assert_eq!(pr_url, Some("https://github.com/example/repo/pull/1"));
}
#[test]
fn fixture_queue_operation_node_parses() {
let json = r#"{
"type": "queue-operation",
"operation": "enqueue",
"timestamp": "2026-03-02T01:02:04.245Z",
"sessionId": "48317b72-a9e4-4c5d-87a4-9f8a6f912e27",
"content": "i see it http://localhost:3000"
}"#;
let mut file = NamedTempFile::new().unwrap();
write_jsonl_fixture(&mut file, json);
let session = parse_session(file.path()).unwrap();
let node = &session.nodes[0];
assert_eq!(node.node_type, NodeType::QueueOperation);
let op = node.extra.as_ref()
.and_then(|e| e.get("operation"))
.and_then(|v| v.as_str());
assert_eq!(op, Some("enqueue"));
}
#[test]
fn fixture_parent_uuid_links_across_full_tool_chain() {
let json = concat!(
r#"{"type":"user","uuid":"node-1","parentUuid":null,"sessionId":"s1","message":{"role":"user","content":"do something"},"timestamp":1000}"#, "\n",
r#"{"type":"assistant","uuid":"node-2","parentUuid":"node-1","sessionId":"s1","message":{"role":"assistant","id":"msg-1","content":[{"type":"tool_use","id":"toolu-1","name":"Bash","input":{"command":"ls"}}]},"timestamp":2000}"#, "\n",
r#"{"type":"user","uuid":"node-3","parentUuid":"node-2","sessionId":"s1","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu-1","content":[{"type":"text","text":"ok"}]}]},"timestamp":3000}"#, "\n"
);
let mut file = NamedTempFile::new().unwrap();
write!(file, "{json}").unwrap();
let session = parse_session(file.path()).unwrap();
assert_eq!(session.nodes.len(), 3);
assert_eq!(session.nodes[0].parent_uuid, None);
assert_eq!(session.nodes[1].parent_uuid.as_deref(), Some("node-1"));
assert_eq!(session.nodes[2].parent_uuid.as_deref(), Some("node-2"));
assert_eq!(session.total_tools, 1);
let result_blocks = session.nodes[2].message.as_ref().unwrap().content_blocks();
assert!(matches!(
result_blocks[0],
crate::parser::models::ContentBlock::ToolResult { ref tool_use_id, .. }
if tool_use_id == "toolu-1"
));
}
#[test]
fn fixture_sidechain_node_parses_is_sidechain_field() {
let json = r#"{
"type": "user",
"uuid": "side-node-1",
"parentUuid": "main-node-1",
"isSidechain": true,
"sessionId": "s1",
"message": { "role": "user", "content": "subagent prompt" },
"timestamp": 1000
}"#;
let mut file = NamedTempFile::new().unwrap();
write_jsonl_fixture(&mut file, json);
let session = parse_session(file.path()).unwrap();
let node = &session.nodes[0];
assert_eq!(node.is_sidechain, Some(true));
assert_eq!(node.parent_uuid.as_deref(), Some("main-node-1"));
}
#[test]
fn fixture_agent_tool_call_counted_in_total_tools() {
let json = r#"{
"type": "assistant",
"uuid": "node-agent",
"parentUuid": "node-user",
"sessionId": "s1",
"message": {
"role": "assistant",
"id": "msg-agent-1",
"content": [
{
"type": "tool_use",
"id": "toolu_agent_01",
"name": "Agent",
"input": {
"prompt": "Explore the project structure thoroughly.",
"subagent_type": "general-purpose"
}
}
]
},
"timestamp": 2000
}"#;
let mut file = NamedTempFile::new().unwrap();
write_jsonl_fixture(&mut file, json);
let session = parse_session(file.path()).unwrap();
assert_eq!(session.total_tools, 1, "Agent tool call must be counted");
let blocks = session.nodes[0].message.as_ref().unwrap().content_blocks();
assert!(
matches!(blocks[0], crate::parser::models::ContentBlock::ToolUse { ref name, .. } if name == "Agent")
);
}
}