Render Agent/Explore/Plan tool calls in a visually distinct collapsible
container with colored left border, replacing the generic tool card. Parse
subagent JSONL transcripts from {sessionId}/subagents/ to extract and
display the actual tool calls (Bash, Read, Grep, etc.) the subagent
executed, reusing the existing ToolCallPart for consistent appearance.
- Add AgentToolCallPart component with collapsible body, prompt section,
execution stats, and nested tool call list via render prop injection
- Add AgentExecutionStats and AgentToolCall types (Rust + TypeScript)
- Parse toolUseResult.agentId to locate and read subagent JSONL files
- Validate agentId against path traversal before filesystem access
- Pass agentStats through adapter for both ID-matched and positional
tool result pairing
- Strip agentStats in nested render to prevent recursive Agent expansion
- Add i18n keys for agent UI labels across all 10 languages
1364 lines
49 KiB
Rust
1364 lines
49 KiB
Rust
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::io::{BufRead, BufReader};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::OnceLock;
|
|
|
|
use chrono::{DateTime, TimeZone, Utc};
|
|
use regex::Regex;
|
|
use serde::Deserialize;
|
|
|
|
use crate::models::*;
|
|
use crate::parsers::{
|
|
compute_session_stats, folder_name_from_path, infer_context_window_max_tokens,
|
|
latest_turn_total_usage_tokens, merge_context_window_stats, truncate_str, AgentParser,
|
|
ParseError,
|
|
};
|
|
|
|
/// Regex to strip the "Sender (untrusted metadata):" block and optional
|
|
/// timestamp prefix from OpenClaw user messages.
|
|
fn sender_block_regex() -> &'static Regex {
|
|
static RE: OnceLock<Regex> = OnceLock::new();
|
|
RE.get_or_init(|| {
|
|
Regex::new(r"(?s)^Sender \(untrusted metadata\):\s*```[^`]*```\s*").unwrap()
|
|
})
|
|
}
|
|
|
|
fn timestamp_prefix_regex() -> &'static Regex {
|
|
static RE: OnceLock<Regex> = OnceLock::new();
|
|
RE.get_or_init(|| Regex::new(r"^\[.*?\]\s*").unwrap())
|
|
}
|
|
|
|
fn working_dir_prefix_regex() -> &'static Regex {
|
|
static RE: OnceLock<Regex> = OnceLock::new();
|
|
RE.get_or_init(|| Regex::new(r"^\[Working directory:[^\]]*\]\s*").unwrap())
|
|
}
|
|
|
|
/// Regex to extract the working directory path from a user message prefix.
|
|
fn working_dir_extract_regex() -> &'static Regex {
|
|
static RE: OnceLock<Regex> = OnceLock::new();
|
|
RE.get_or_init(|| Regex::new(r"\[Working directory:\s*([^\]]+)\]").unwrap())
|
|
}
|
|
|
|
/// Extract the working directory from OpenClaw user message text.
|
|
/// Returns the expanded path (~ replaced with home dir).
|
|
fn extract_working_dir(text: &str) -> Option<String> {
|
|
let captures = working_dir_extract_regex().captures(text)?;
|
|
let raw_path = captures.get(1)?.as_str().trim();
|
|
if raw_path.is_empty() {
|
|
return None;
|
|
}
|
|
// Expand ~ to home directory
|
|
if let Some(stripped) = raw_path.strip_prefix("~/") {
|
|
if let Some(home) = dirs::home_dir() {
|
|
return Some(home.join(stripped).to_string_lossy().to_string());
|
|
}
|
|
}
|
|
Some(raw_path.to_string())
|
|
}
|
|
|
|
/// Strip OpenClaw user message prefix metadata.
|
|
fn strip_openclaw_user_prefix(text: &str) -> String {
|
|
let cleaned = sender_block_regex().replace(text, "");
|
|
let cleaned = timestamp_prefix_regex().replace(&cleaned, "");
|
|
let cleaned = working_dir_prefix_regex().replace(&cleaned, "");
|
|
cleaned.trim().to_string()
|
|
}
|
|
|
|
// ── sessions.json deserialization ──────────────────────────────────────
|
|
|
|
#[derive(Deserialize, Clone)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct SessionMeta {
|
|
session_id: String,
|
|
updated_at: Option<u64>,
|
|
model: Option<String>,
|
|
context_tokens: Option<u64>,
|
|
#[allow(dead_code)]
|
|
input_tokens: Option<u64>,
|
|
#[allow(dead_code)]
|
|
output_tokens: Option<u64>,
|
|
#[allow(dead_code)]
|
|
cache_read: Option<u64>,
|
|
#[allow(dead_code)]
|
|
cache_write: Option<u64>,
|
|
#[allow(dead_code)]
|
|
total_tokens: Option<u64>,
|
|
origin: Option<SessionOrigin>,
|
|
}
|
|
|
|
#[derive(Deserialize, Clone)]
|
|
struct SessionOrigin {
|
|
label: Option<String>,
|
|
}
|
|
|
|
// ── JSONL tree ────────────────────────────────────────────────────────
|
|
|
|
/// A parsed JSONL record, indexed by its id.
|
|
#[derive(Clone)]
|
|
struct JRecord {
|
|
id: String,
|
|
parent_id: Option<String>,
|
|
record_type: String,
|
|
value: serde_json::Value,
|
|
}
|
|
|
|
/// Parsed tree of JSONL records.
|
|
struct JTree {
|
|
records: HashMap<String, JRecord>,
|
|
/// id → list of child ids (in insertion order)
|
|
children: HashMap<String, Vec<String>>,
|
|
/// Records with parentId = null (roots)
|
|
#[allow(dead_code)]
|
|
roots: Vec<String>,
|
|
/// Session cwd from the "session" header
|
|
session_cwd: Option<String>,
|
|
/// True if no parent has more than one child (no forks → single linear chain)
|
|
is_linear: bool,
|
|
/// Record ids in file insertion order (used for fast path when is_linear)
|
|
insertion_order: Vec<String>,
|
|
}
|
|
|
|
impl JTree {
|
|
fn parse(path: &Path) -> Result<Self, ParseError> {
|
|
let file = fs::File::open(path)?;
|
|
let reader = BufReader::new(file);
|
|
|
|
let mut records = HashMap::new();
|
|
let mut children: HashMap<String, Vec<String>> = HashMap::new();
|
|
let mut roots = Vec::new();
|
|
let mut session_cwd = None;
|
|
// Maintain insertion order for roots
|
|
let mut insert_order: Vec<String> = Vec::new();
|
|
|
|
for line in reader.lines() {
|
|
let line = match line {
|
|
Ok(l) => l,
|
|
Err(_) => continue,
|
|
};
|
|
if line.trim().is_empty() {
|
|
continue;
|
|
}
|
|
let value: serde_json::Value = match serde_json::from_str(&line) {
|
|
Ok(v) => v,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
let record_type = value
|
|
.get("type")
|
|
.and_then(|t| t.as_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let id = value
|
|
.get("id")
|
|
.and_then(|i| i.as_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let parent_id = value
|
|
.get("parentId")
|
|
.and_then(|p| p.as_str())
|
|
.map(|s| s.to_string());
|
|
|
|
if record_type == "session" {
|
|
if session_cwd.is_none() {
|
|
session_cwd = value
|
|
.get("cwd")
|
|
.and_then(|s| s.as_str())
|
|
.map(|s| s.to_string());
|
|
}
|
|
continue; // session records don't participate in the tree
|
|
}
|
|
|
|
if id.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let rec = JRecord {
|
|
id: id.clone(),
|
|
parent_id: parent_id.clone(),
|
|
record_type,
|
|
value,
|
|
};
|
|
|
|
match &parent_id {
|
|
Some(pid) => {
|
|
children.entry(pid.clone()).or_default().push(id.clone());
|
|
}
|
|
None => {
|
|
roots.push(id.clone());
|
|
}
|
|
}
|
|
|
|
insert_order.push(id.clone());
|
|
records.insert(id, rec);
|
|
}
|
|
|
|
let is_linear = children.values().all(|kids| kids.len() <= 1);
|
|
|
|
Ok(JTree {
|
|
records,
|
|
children,
|
|
roots,
|
|
session_cwd,
|
|
is_linear,
|
|
insertion_order: insert_order,
|
|
})
|
|
}
|
|
|
|
/// Walk up the parentId chain from `leaf_id` to root, returning the path
|
|
/// from root → leaf (reversed).
|
|
fn ancestor_chain(&self, leaf_id: &str) -> Vec<String> {
|
|
let mut chain = Vec::new();
|
|
let mut current = Some(leaf_id.to_string());
|
|
while let Some(id) = current {
|
|
if let Some(rec) = self.records.get(&id) {
|
|
chain.push(id);
|
|
current = rec.parent_id.clone();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
chain.reverse();
|
|
chain
|
|
}
|
|
|
|
/// Find all leaf nodes (nodes that have no children).
|
|
fn leaf_ids(&self) -> Vec<String> {
|
|
self.records
|
|
.keys()
|
|
.filter(|id| !self.children.contains_key(*id) || self.children[*id].is_empty())
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
/// Find the first user message in a branch (root → leaf path).
|
|
/// This is the "fork point" user message that starts this conversation.
|
|
fn find_branch_first_user_message(&self, branch: &[String]) -> Option<usize> {
|
|
// Walk the branch and find where this branch diverges from others.
|
|
// The first user message at or after the fork point is the conversation start.
|
|
for (i, id) in branch.iter().enumerate() {
|
|
if let Some(rec) = self.records.get(id) {
|
|
if rec.record_type != "message" {
|
|
continue;
|
|
}
|
|
let role = rec
|
|
.value
|
|
.get("message")
|
|
.and_then(|m| m.get("role"))
|
|
.and_then(|r| r.as_str())
|
|
.unwrap_or("");
|
|
if role != "user" {
|
|
continue;
|
|
}
|
|
// Check if parent has multiple children (fork point)
|
|
if let Some(pid) = &rec.parent_id {
|
|
if let Some(siblings) = self.children.get(pid) {
|
|
if siblings.len() > 1 {
|
|
return Some(i);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// No fork point found → the first user message is the conversation start
|
|
for (i, id) in branch.iter().enumerate() {
|
|
if let Some(rec) = self.records.get(id) {
|
|
if rec.record_type == "message" {
|
|
let role = rec
|
|
.value
|
|
.get("message")
|
|
.and_then(|m| m.get("role"))
|
|
.and_then(|r| r.as_str())
|
|
.unwrap_or("");
|
|
if role == "user" {
|
|
return Some(i);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Extract distinct conversation branches.
|
|
/// Each branch is identified by its leaf node.
|
|
/// Returns: Vec<(leaf_id, branch_record_ids from fork_user_msg → leaf)>
|
|
fn conversation_branches(&self) -> Vec<(String, Vec<String>)> {
|
|
// Fast path: linear chain (no forks) → single branch using insertion order
|
|
if self.is_linear {
|
|
if let Some(leaf_id) = self.insertion_order.last() {
|
|
// Find the first user message in insertion order
|
|
let first_user_idx = self.insertion_order.iter().position(|id| {
|
|
self.records.get(id).is_some_and(|r| {
|
|
r.record_type == "message"
|
|
&& r.value
|
|
.get("message")
|
|
.and_then(|m| m.get("role"))
|
|
.and_then(|r| r.as_str())
|
|
== Some("user")
|
|
})
|
|
});
|
|
if let Some(idx) = first_user_idx {
|
|
let branch_ids = self.insertion_order[idx..].to_vec();
|
|
return vec![(leaf_id.clone(), branch_ids)];
|
|
}
|
|
}
|
|
return vec![];
|
|
}
|
|
|
|
let leaves = self.leaf_ids();
|
|
let mut branches = Vec::new();
|
|
|
|
for leaf_id in &leaves {
|
|
let chain = self.ancestor_chain(leaf_id);
|
|
if chain.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
// Find the fork-point user message for this branch
|
|
if let Some(fork_idx) = self.find_branch_first_user_message(&chain) {
|
|
let branch_ids: Vec<String> = chain[fork_idx..].to_vec();
|
|
// Only include branches that have at least one user message
|
|
let has_user = branch_ids.iter().any(|id| {
|
|
self.records.get(id).is_some_and(|r| {
|
|
r.record_type == "message"
|
|
&& r.value
|
|
.get("message")
|
|
.and_then(|m| m.get("role"))
|
|
.and_then(|r| r.as_str())
|
|
== Some("user")
|
|
})
|
|
});
|
|
if has_user {
|
|
branches.push((leaf_id.clone(), branch_ids));
|
|
}
|
|
}
|
|
}
|
|
|
|
branches
|
|
}
|
|
}
|
|
|
|
// ── Parser ─────────────────────────────────────────────────────────────
|
|
|
|
pub struct OpenClawParser {
|
|
base_dir: PathBuf,
|
|
}
|
|
|
|
impl OpenClawParser {
|
|
pub fn new() -> Self {
|
|
let base_dir = dirs::home_dir()
|
|
.unwrap_or_default()
|
|
.join(".openclaw")
|
|
.join("agents");
|
|
Self { base_dir }
|
|
}
|
|
|
|
/// Read sessions.json for a given agent directory.
|
|
fn read_session_index(
|
|
agent_dir: &Path,
|
|
) -> Result<HashMap<String, SessionMeta>, ParseError> {
|
|
let index_path = agent_dir.join("sessions").join("sessions.json");
|
|
if !index_path.exists() {
|
|
return Ok(HashMap::new());
|
|
}
|
|
let content = fs::read_to_string(&index_path)?;
|
|
let index: HashMap<String, SessionMeta> = serde_json::from_str(&content)?;
|
|
Ok(index)
|
|
}
|
|
|
|
/// List all JSONL files for an agent, including `.jsonl.reset.*` archives.
|
|
fn list_jsonl_files(agent_dir: &Path) -> Vec<(String, PathBuf)> {
|
|
let sessions_dir = agent_dir.join("sessions");
|
|
if !sessions_dir.exists() {
|
|
return Vec::new();
|
|
}
|
|
let mut files = Vec::new();
|
|
if let Ok(entries) = fs::read_dir(&sessions_dir) {
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
let name = path
|
|
.file_name()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.to_string();
|
|
// Match both <uuid>.jsonl and <uuid>.jsonl.reset.<timestamp>
|
|
if let Some(session_id) = extract_session_id_from_filename(&name) {
|
|
files.push((session_id, path));
|
|
}
|
|
}
|
|
}
|
|
files
|
|
}
|
|
|
|
/// Build summaries for all conversation branches in a single JSONL file.
|
|
fn summaries_from_tree(
|
|
agent_id: &str,
|
|
session_id: &str,
|
|
tree: &JTree,
|
|
session_meta: Option<&SessionMeta>,
|
|
) -> Vec<ConversationSummary> {
|
|
let branches = tree.conversation_branches();
|
|
let mut summaries = Vec::new();
|
|
|
|
for (leaf_id, branch_ids) in &branches {
|
|
let mut cwd = tree.session_cwd.clone();
|
|
let mut title: Option<String> = None;
|
|
let mut first_timestamp: Option<DateTime<Utc>> = None;
|
|
let mut last_timestamp: Option<DateTime<Utc>> = None;
|
|
let mut message_count: u32 = 0;
|
|
|
|
for id in branch_ids {
|
|
let rec = match tree.records.get(id) {
|
|
Some(r) => r,
|
|
None => continue,
|
|
};
|
|
|
|
if let Some(ts) = parse_iso_timestamp(&rec.value) {
|
|
if first_timestamp.is_none() {
|
|
first_timestamp = Some(ts);
|
|
}
|
|
last_timestamp = Some(ts);
|
|
}
|
|
|
|
if rec.record_type != "message" {
|
|
continue;
|
|
}
|
|
|
|
let role = rec
|
|
.value
|
|
.get("message")
|
|
.and_then(|m| m.get("role"))
|
|
.and_then(|r| r.as_str())
|
|
.unwrap_or("");
|
|
|
|
match role {
|
|
"user" => {
|
|
message_count += 1;
|
|
if let Some(text) = extract_first_text_content(&rec.value) {
|
|
if let Some(wd) = extract_working_dir(&text) {
|
|
cwd = Some(wd);
|
|
}
|
|
if title.is_none() {
|
|
let cleaned = strip_openclaw_user_prefix(&text);
|
|
if !cleaned.is_empty() {
|
|
title = Some(truncate_str(&cleaned, 100));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"assistant" => {
|
|
message_count += 1;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
let started_at = match first_timestamp {
|
|
Some(ts) => ts,
|
|
None => continue,
|
|
};
|
|
|
|
// Use updatedAt from sessions.json if this is the latest branch
|
|
let ended_at = session_meta
|
|
.and_then(|m| m.updated_at)
|
|
.and_then(|ms| Utc.timestamp_millis_opt(ms as i64).single())
|
|
.or(last_timestamp);
|
|
|
|
if title.is_none() {
|
|
title = session_meta
|
|
.and_then(|m| m.origin.as_ref())
|
|
.and_then(|o| o.label.clone());
|
|
}
|
|
|
|
// conversation_id: agentId/sessionId/leafId
|
|
let conversation_id = format!("{}/{}/{}", agent_id, session_id, leaf_id);
|
|
let folder_path = cwd.clone();
|
|
let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p));
|
|
|
|
summaries.push(ConversationSummary {
|
|
id: conversation_id,
|
|
agent_type: AgentType::OpenClaw,
|
|
folder_path,
|
|
folder_name,
|
|
title,
|
|
started_at,
|
|
ended_at,
|
|
message_count,
|
|
model: session_meta.and_then(|m| m.model.clone()),
|
|
git_branch: None,
|
|
});
|
|
}
|
|
|
|
summaries
|
|
}
|
|
|
|
/// Parse a JSONL file (tree-aware) to extract full conversation detail
|
|
/// for a specific branch identified by leaf_id.
|
|
fn parse_conversation_detail(
|
|
jsonl_path: &Path,
|
|
conversation_id: &str,
|
|
leaf_id: Option<&str>,
|
|
session_meta: Option<&SessionMeta>,
|
|
) -> Result<ConversationDetail, ParseError> {
|
|
let tree = JTree::parse(jsonl_path)?;
|
|
|
|
// Determine which branch to display
|
|
let branch_ids = if let Some(lid) = leaf_id {
|
|
// Specific branch: ancestor chain from leaf, starting from fork user msg
|
|
let chain = tree.ancestor_chain(lid);
|
|
if chain.is_empty() {
|
|
return Err(ParseError::ConversationNotFound(
|
|
conversation_id.to_string(),
|
|
));
|
|
}
|
|
match tree.find_branch_first_user_message(&chain) {
|
|
Some(idx) => chain[idx..].to_vec(),
|
|
None => chain,
|
|
}
|
|
} else {
|
|
// No leaf_id: use the full chain of the most recently updated leaf
|
|
// (backward compat for old conversation IDs without leaf component)
|
|
let branches = tree.conversation_branches();
|
|
if branches.is_empty() {
|
|
// Fallback: use all message records in order
|
|
tree.records
|
|
.values()
|
|
.filter(|r| r.record_type == "message")
|
|
.map(|r| r.id.clone())
|
|
.collect()
|
|
} else {
|
|
// Find branch with latest timestamp
|
|
let mut best: Option<(DateTime<Utc>, Vec<String>)> = None;
|
|
for (_, branch) in &branches {
|
|
let ts = branch
|
|
.iter()
|
|
.filter_map(|id| tree.records.get(id))
|
|
.filter_map(|r| parse_iso_timestamp(&r.value))
|
|
.next_back();
|
|
if let Some(ts) = ts {
|
|
if best.as_ref().is_none_or(|(t, _)| ts > *t) {
|
|
best = Some((ts, branch.clone()));
|
|
}
|
|
}
|
|
}
|
|
best.map(|(_, b)| b).unwrap_or_default()
|
|
}
|
|
};
|
|
|
|
let mut messages: Vec<UnifiedMessage> = Vec::new();
|
|
let mut cwd = tree.session_cwd.clone();
|
|
let mut model: Option<String> = None;
|
|
let mut title: Option<String> = None;
|
|
let mut first_timestamp: Option<DateTime<Utc>> = None;
|
|
let mut last_timestamp: Option<DateTime<Utc>> = None;
|
|
|
|
for id in &branch_ids {
|
|
let rec = match tree.records.get(id) {
|
|
Some(r) => r,
|
|
None => continue,
|
|
};
|
|
|
|
if rec.record_type != "message" {
|
|
continue;
|
|
}
|
|
|
|
if let Some(ts) = parse_iso_timestamp(&rec.value) {
|
|
if first_timestamp.is_none() {
|
|
first_timestamp = Some(ts);
|
|
}
|
|
last_timestamp = Some(ts);
|
|
}
|
|
|
|
let role = rec
|
|
.value
|
|
.get("message")
|
|
.and_then(|m| m.get("role"))
|
|
.and_then(|r| r.as_str())
|
|
.unwrap_or("");
|
|
let timestamp = parse_iso_timestamp(&rec.value).unwrap_or_else(Utc::now);
|
|
let msg_id = rec.id.clone();
|
|
|
|
match role {
|
|
"user" => {
|
|
if let Some(raw_text) = extract_first_text_content(&rec.value) {
|
|
if let Some(wd) = extract_working_dir(&raw_text) {
|
|
cwd = Some(wd);
|
|
}
|
|
}
|
|
let content = extract_user_content(&rec.value);
|
|
if content.is_empty() {
|
|
continue;
|
|
}
|
|
if title.is_none() {
|
|
if let Some(ContentBlock::Text { ref text }) = content.first() {
|
|
title = Some(truncate_str(text, 100));
|
|
}
|
|
}
|
|
messages.push(UnifiedMessage {
|
|
id: msg_id,
|
|
role: MessageRole::User,
|
|
content,
|
|
timestamp,
|
|
usage: None,
|
|
duration_ms: None,
|
|
model: None,
|
|
});
|
|
}
|
|
"assistant" => {
|
|
let content = extract_assistant_content(&rec.value);
|
|
let usage = extract_usage(&rec.value);
|
|
let msg_model = rec
|
|
.value
|
|
.get("message")
|
|
.and_then(|m| m.get("model"))
|
|
.and_then(|m| m.as_str())
|
|
.map(|s| s.to_string());
|
|
if model.is_none() {
|
|
model = msg_model.clone();
|
|
}
|
|
messages.push(UnifiedMessage {
|
|
id: msg_id,
|
|
role: MessageRole::Assistant,
|
|
content,
|
|
timestamp,
|
|
usage,
|
|
duration_ms: None,
|
|
model: msg_model,
|
|
});
|
|
}
|
|
"toolResult" => {
|
|
let content = extract_tool_result_content(&rec.value);
|
|
messages.push(UnifiedMessage {
|
|
id: msg_id,
|
|
role: MessageRole::Tool,
|
|
content,
|
|
timestamp,
|
|
usage: None,
|
|
duration_ms: None,
|
|
model: None,
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
if let Some(meta) = session_meta {
|
|
if model.is_none() {
|
|
model = meta.model.clone();
|
|
}
|
|
}
|
|
|
|
let folder_path = cwd.clone();
|
|
let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p));
|
|
let mut turns = group_into_turns(messages);
|
|
super::relocate_orphaned_tool_results(&mut turns);
|
|
super::structurize_read_tool_output(&mut turns);
|
|
super::resolve_patch_line_numbers(&mut turns, cwd.as_deref());
|
|
|
|
let context_window_used_tokens = latest_turn_total_usage_tokens(&turns);
|
|
let context_window_max_tokens = session_meta
|
|
.and_then(|m| m.context_tokens)
|
|
.or_else(|| infer_context_window_max_tokens(model.as_deref()));
|
|
let session_stats = merge_context_window_stats(
|
|
compute_session_stats(&turns),
|
|
context_window_used_tokens,
|
|
context_window_max_tokens,
|
|
);
|
|
|
|
let summary = ConversationSummary {
|
|
id: conversation_id.to_string(),
|
|
agent_type: AgentType::OpenClaw,
|
|
folder_path,
|
|
folder_name,
|
|
title,
|
|
started_at: first_timestamp.unwrap_or_else(Utc::now),
|
|
ended_at: last_timestamp,
|
|
message_count: turns.len() as u32,
|
|
model,
|
|
git_branch: None,
|
|
};
|
|
|
|
Ok(ConversationDetail {
|
|
summary,
|
|
turns,
|
|
session_stats,
|
|
})
|
|
}
|
|
|
|
/// Resolve a conversation_id to (jsonl_path, leaf_id, session_meta).
|
|
///
|
|
/// Conversation ID formats:
|
|
/// - `agentId/sessionId/leafId` — tree-aware branch
|
|
/// - `agentId/sessionId` — legacy, uses latest branch
|
|
/// - bare UUID — ACP session ID fallback
|
|
fn resolve_session(
|
|
&self,
|
|
conversation_id: &str,
|
|
) -> Result<(PathBuf, Option<String>, Option<SessionMeta>), ParseError> {
|
|
let parts: Vec<&str> = conversation_id.splitn(3, '/').collect();
|
|
|
|
if parts.len() >= 2 {
|
|
let agent_id = parts[0];
|
|
let session_id = parts[1];
|
|
let leaf_id = parts.get(2).map(|s| s.to_string());
|
|
let agent_dir = self.base_dir.join(agent_id);
|
|
|
|
// Try exact JSONL file
|
|
let jsonl_path = agent_dir
|
|
.join("sessions")
|
|
.join(format!("{}.jsonl", session_id));
|
|
if jsonl_path.exists() {
|
|
let meta = Self::read_session_index(&agent_dir)
|
|
.ok()
|
|
.and_then(|index| {
|
|
index
|
|
.into_values()
|
|
.find(|m| m.session_id == session_id)
|
|
});
|
|
return Ok((jsonl_path, leaf_id, meta));
|
|
}
|
|
|
|
// Try reset files
|
|
if let Some((path, meta)) =
|
|
Self::find_reset_file(&agent_dir, session_id)
|
|
{
|
|
return Ok((path, leaf_id, meta));
|
|
}
|
|
}
|
|
|
|
// Fallback: scan all agent directories
|
|
if self.base_dir.exists() {
|
|
let bare_id = match parts.len() {
|
|
1 => parts[0],
|
|
2 => parts[1],
|
|
_ => parts[1],
|
|
};
|
|
|
|
for entry in fs::read_dir(&self.base_dir)?.flatten() {
|
|
let agent_dir = entry.path();
|
|
if !agent_dir.is_dir() {
|
|
continue;
|
|
}
|
|
|
|
// Try direct session ID match
|
|
let jsonl_path = agent_dir
|
|
.join("sessions")
|
|
.join(format!("{}.jsonl", bare_id));
|
|
if jsonl_path.exists() {
|
|
let meta = Self::read_session_index(&agent_dir)
|
|
.ok()
|
|
.and_then(|index| {
|
|
index.into_values().find(|m| m.session_id == bare_id)
|
|
});
|
|
return Ok((jsonl_path, None, meta));
|
|
}
|
|
|
|
// Try reset files
|
|
if let Some((path, meta)) = Self::find_reset_file(&agent_dir, bare_id) {
|
|
return Ok((path, None, meta));
|
|
}
|
|
|
|
// Fallback: ACP session ID doesn't match any file.
|
|
// Scan all JSONL files and search for a branch whose leaf_id
|
|
// or first user message timestamp matches. As a last resort,
|
|
// try the most recently updated session.
|
|
let jsonl_files = Self::list_jsonl_files(&agent_dir);
|
|
for (sid, path) in &jsonl_files {
|
|
if let Ok(tree) = JTree::parse(path) {
|
|
// Check if any leaf id matches the bare_id
|
|
let leaves = tree.leaf_ids();
|
|
if leaves.iter().any(|l| l == bare_id) {
|
|
let meta = Self::read_session_index(&agent_dir)
|
|
.ok()
|
|
.and_then(|index| {
|
|
index.into_values().find(|m| m.session_id == *sid)
|
|
});
|
|
return Ok((path.clone(), Some(bare_id.to_string()), meta));
|
|
}
|
|
}
|
|
}
|
|
|
|
// No fallback: if the ID doesn't match any file or leaf,
|
|
// return ConversationNotFound to avoid showing wrong messages.
|
|
}
|
|
}
|
|
|
|
Err(ParseError::ConversationNotFound(
|
|
conversation_id.to_string(),
|
|
))
|
|
}
|
|
|
|
/// Find a `.jsonl.reset.*` file matching the given session_id.
|
|
fn find_reset_file(
|
|
agent_dir: &Path,
|
|
session_id: &str,
|
|
) -> Option<(PathBuf, Option<SessionMeta>)> {
|
|
let sessions_dir = agent_dir.join("sessions");
|
|
if !sessions_dir.exists() {
|
|
return None;
|
|
}
|
|
let prefix = format!("{}.jsonl.reset.", session_id);
|
|
let mut candidates: Vec<(PathBuf, String)> = Vec::new();
|
|
if let Ok(entries) = fs::read_dir(&sessions_dir) {
|
|
for entry in entries.flatten() {
|
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
if name.starts_with(&prefix) {
|
|
// Extract timestamp suffix for sorting
|
|
let suffix = name[prefix.len()..].to_string();
|
|
candidates.push((entry.path(), suffix));
|
|
}
|
|
}
|
|
}
|
|
if candidates.is_empty() {
|
|
return None;
|
|
}
|
|
// Sort by timestamp suffix descending to get the latest reset file
|
|
candidates.sort_by(|a, b| b.1.cmp(&a.1));
|
|
Some((candidates.into_iter().next().unwrap().0, None))
|
|
}
|
|
}
|
|
|
|
impl AgentParser for OpenClawParser {
|
|
fn list_conversations(&self) -> Result<Vec<ConversationSummary>, ParseError> {
|
|
let mut conversations = Vec::new();
|
|
|
|
if !self.base_dir.exists() {
|
|
return Ok(conversations);
|
|
}
|
|
|
|
for entry in fs::read_dir(&self.base_dir)?.flatten() {
|
|
let agent_dir = entry.path();
|
|
if !agent_dir.is_dir() {
|
|
continue;
|
|
}
|
|
|
|
let agent_id = agent_dir
|
|
.file_name()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.to_string();
|
|
|
|
let index = Self::read_session_index(&agent_dir).unwrap_or_default();
|
|
|
|
// Scan all JSONL files (including reset archives)
|
|
let jsonl_files = Self::list_jsonl_files(&agent_dir);
|
|
for (session_id, path) in &jsonl_files {
|
|
let tree = match JTree::parse(path) {
|
|
Ok(t) => t,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
let meta = index
|
|
.values()
|
|
.find(|m| m.session_id == *session_id)
|
|
.cloned();
|
|
|
|
let summaries =
|
|
Self::summaries_from_tree(&agent_id, session_id, &tree, meta.as_ref());
|
|
conversations.extend(summaries);
|
|
}
|
|
}
|
|
|
|
conversations.sort_by(|a, b| b.started_at.cmp(&a.started_at));
|
|
Ok(conversations)
|
|
}
|
|
|
|
fn get_conversation(&self, conversation_id: &str) -> Result<ConversationDetail, ParseError> {
|
|
let (jsonl_path, leaf_id, meta) = self.resolve_session(conversation_id)?;
|
|
Self::parse_conversation_detail(
|
|
&jsonl_path,
|
|
conversation_id,
|
|
leaf_id.as_deref(),
|
|
meta.as_ref(),
|
|
)
|
|
}
|
|
}
|
|
|
|
// ── Helper functions ───────────────────────────────────────────────────
|
|
|
|
/// Extract session UUID from filenames like `<uuid>.jsonl` or `<uuid>.jsonl.reset.<ts>`.
|
|
fn extract_session_id_from_filename(name: &str) -> Option<String> {
|
|
// Skip non-jsonl files
|
|
if !name.contains(".jsonl") {
|
|
return None;
|
|
}
|
|
// Skip sessions.json
|
|
if name == "sessions.json" {
|
|
return None;
|
|
}
|
|
// Extract the UUID part before .jsonl
|
|
let uuid_part = name.split(".jsonl").next()?;
|
|
if uuid_part.is_empty() {
|
|
return None;
|
|
}
|
|
Some(uuid_part.to_string())
|
|
}
|
|
|
|
fn parse_iso_timestamp(value: &serde_json::Value) -> Option<DateTime<Utc>> {
|
|
value
|
|
.get("timestamp")
|
|
.and_then(|t| t.as_str())
|
|
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
|
|
}
|
|
|
|
fn extract_first_text_content(value: &serde_json::Value) -> Option<String> {
|
|
let content = value.get("message")?.get("content")?.as_array()?;
|
|
for item in content {
|
|
if item.get("type").and_then(|t| t.as_str()) == Some("text") {
|
|
return item.get("text").and_then(|t| t.as_str()).map(|s| s.to_string());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn extract_user_content(value: &serde_json::Value) -> Vec<ContentBlock> {
|
|
let mut blocks = Vec::new();
|
|
let message = match value.get("message") {
|
|
Some(m) => m,
|
|
None => return blocks,
|
|
};
|
|
let content = match message.get("content") {
|
|
Some(c) => c,
|
|
None => return blocks,
|
|
};
|
|
|
|
if let Some(arr) = content.as_array() {
|
|
for item in arr {
|
|
let block_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
|
if block_type == "text" {
|
|
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
|
|
let cleaned = strip_openclaw_user_prefix(text);
|
|
if !cleaned.is_empty() {
|
|
blocks.push(ContentBlock::Text { text: cleaned });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
blocks
|
|
}
|
|
|
|
fn extract_assistant_content(value: &serde_json::Value) -> Vec<ContentBlock> {
|
|
let mut blocks = Vec::new();
|
|
let message = match value.get("message") {
|
|
Some(m) => m,
|
|
None => return blocks,
|
|
};
|
|
let content = match message.get("content") {
|
|
Some(c) => c,
|
|
None => return blocks,
|
|
};
|
|
|
|
if let Some(arr) = content.as_array() {
|
|
for item in arr {
|
|
let block_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
|
match block_type {
|
|
"text" => {
|
|
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
|
|
// Strip [[reply_to_current]] prefix if present
|
|
let cleaned = text
|
|
.strip_prefix("[[reply_to_current]] ")
|
|
.unwrap_or(text)
|
|
.to_string();
|
|
if !cleaned.is_empty() {
|
|
blocks.push(ContentBlock::Text { text: cleaned });
|
|
}
|
|
}
|
|
}
|
|
"thinking" => {
|
|
if let Some(text) = item.get("thinking").and_then(|t| t.as_str()) {
|
|
if !text.is_empty() {
|
|
blocks.push(ContentBlock::Thinking {
|
|
text: text.to_string(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
"toolCall" => {
|
|
let tool_use_id = item
|
|
.get("id")
|
|
.and_then(|n| n.as_str())
|
|
.map(|s| s.to_string());
|
|
let tool_name = item
|
|
.get("name")
|
|
.and_then(|n| n.as_str())
|
|
.unwrap_or("unknown")
|
|
.to_string();
|
|
let is_edit_tool = matches!(
|
|
tool_name.to_lowercase().as_str(),
|
|
"edit" | "write" | "apply_patch" | "patch" | "applypatch"
|
|
| "edit_file" | "editfile"
|
|
);
|
|
let max_len = if is_edit_tool { 50000 } else { 500 };
|
|
let input_preview = item.get("arguments").map(|a| {
|
|
let s = a.to_string();
|
|
truncate_str(&s, max_len)
|
|
});
|
|
blocks.push(ContentBlock::ToolUse {
|
|
tool_use_id,
|
|
tool_name,
|
|
input_preview,
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
blocks
|
|
}
|
|
|
|
fn extract_tool_result_content(value: &serde_json::Value) -> Vec<ContentBlock> {
|
|
let mut blocks = Vec::new();
|
|
let message = match value.get("message") {
|
|
Some(m) => m,
|
|
None => return blocks,
|
|
};
|
|
|
|
let tool_use_id = message
|
|
.get("toolCallId")
|
|
.and_then(|n| n.as_str())
|
|
.map(|s| s.to_string());
|
|
|
|
let is_error = message
|
|
.get("isError")
|
|
.and_then(|e| e.as_bool())
|
|
.unwrap_or(false);
|
|
|
|
let output = message
|
|
.get("content")
|
|
.and_then(|c| c.as_array())
|
|
.and_then(|arr| {
|
|
let texts: Vec<String> = arr
|
|
.iter()
|
|
.filter_map(|item| {
|
|
if item.get("type").and_then(|t| t.as_str()) == Some("text") {
|
|
item.get("text")
|
|
.and_then(|t| t.as_str())
|
|
.map(|s| s.to_string())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
if texts.is_empty() {
|
|
None
|
|
} else {
|
|
Some(texts.join("\n"))
|
|
}
|
|
});
|
|
|
|
blocks.push(ContentBlock::ToolResult {
|
|
tool_use_id,
|
|
output_preview: output,
|
|
is_error,
|
|
agent_stats: None,
|
|
});
|
|
|
|
blocks
|
|
}
|
|
|
|
fn extract_usage(value: &serde_json::Value) -> Option<TurnUsage> {
|
|
let usage = value.get("message")?.get("usage")?;
|
|
Some(TurnUsage {
|
|
input_tokens: usage.get("input").and_then(|v| v.as_u64()).unwrap_or(0),
|
|
output_tokens: usage.get("output").and_then(|v| v.as_u64()).unwrap_or(0),
|
|
cache_creation_input_tokens: usage
|
|
.get("cacheWrite")
|
|
.and_then(|v| v.as_u64())
|
|
.unwrap_or(0),
|
|
cache_read_input_tokens: usage
|
|
.get("cacheRead")
|
|
.and_then(|v| v.as_u64())
|
|
.unwrap_or(0),
|
|
})
|
|
}
|
|
|
|
/// Group flat messages into conversation turns.
|
|
/// Assistant + Tool messages merge into one Assistant turn.
|
|
fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
|
|
let mut turns = Vec::new();
|
|
let mut i = 0;
|
|
|
|
while i < messages.len() {
|
|
let msg = &messages[i];
|
|
|
|
if matches!(msg.role, MessageRole::User) {
|
|
turns.push(MessageTurn {
|
|
id: format!("turn-{}", turns.len()),
|
|
role: TurnRole::User,
|
|
blocks: msg.content.clone(),
|
|
timestamp: msg.timestamp,
|
|
usage: None,
|
|
duration_ms: None,
|
|
model: None,
|
|
});
|
|
i += 1;
|
|
} else if matches!(msg.role, MessageRole::System) {
|
|
turns.push(MessageTurn {
|
|
id: format!("turn-{}", turns.len()),
|
|
role: TurnRole::System,
|
|
blocks: msg.content.clone(),
|
|
timestamp: msg.timestamp,
|
|
usage: None,
|
|
duration_ms: None,
|
|
model: None,
|
|
});
|
|
i += 1;
|
|
} else {
|
|
// Assistant or Tool — start a group
|
|
let mut blocks: Vec<ContentBlock> = msg.content.clone();
|
|
let mut usage = msg.usage.clone();
|
|
let duration_ms = msg.duration_ms;
|
|
let turn_model = msg.model.clone();
|
|
let timestamp = msg.timestamp;
|
|
i += 1;
|
|
|
|
// Only absorb immediately following Tool messages
|
|
// (stop at the next assistant message to keep turns small for virtualization)
|
|
while i < messages.len()
|
|
&& matches!(messages[i].role, MessageRole::Tool)
|
|
{
|
|
blocks.extend(messages[i].content.clone());
|
|
if usage.is_none() {
|
|
usage = messages[i].usage.clone();
|
|
}
|
|
i += 1;
|
|
}
|
|
|
|
turns.push(MessageTurn {
|
|
id: format!("turn-{}", turns.len()),
|
|
role: TurnRole::Assistant,
|
|
blocks,
|
|
timestamp,
|
|
usage,
|
|
duration_ms,
|
|
model: turn_model,
|
|
});
|
|
}
|
|
}
|
|
|
|
turns
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
use std::io::Write;
|
|
|
|
#[test]
|
|
fn strips_sender_block_and_timestamp() {
|
|
let input = "Sender (untrusted metadata):\n```json\n{\"label\": \"test\"}\n```\n\n[Tue 2026-03-17 12:56 GMT+8] Hello world";
|
|
assert_eq!(strip_openclaw_user_prefix(input), "Hello world");
|
|
}
|
|
|
|
#[test]
|
|
fn strips_timestamp_only() {
|
|
let input = "[Tue 2026-03-17 12:56 GMT+8] Hello";
|
|
assert_eq!(strip_openclaw_user_prefix(input), "Hello");
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_working_directory() {
|
|
let text = "[Tue 2026-03-17 12:58 GMT+8] [Working directory: ~/forway/agent-workspace]\n\nHello";
|
|
let wd = extract_working_dir(text).unwrap();
|
|
let home = dirs::home_dir().unwrap().to_string_lossy().to_string();
|
|
assert_eq!(wd, format!("{}/forway/agent-workspace", home));
|
|
}
|
|
|
|
#[test]
|
|
fn extract_working_dir_returns_none_for_plain_text() {
|
|
assert!(extract_working_dir("Hello world").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn strips_working_dir_prefix() {
|
|
let input = "[Tue 2026-03-17 12:58 GMT+8] [Working directory: ~/projects/test]\n\nHello";
|
|
let result = strip_openclaw_user_prefix(input);
|
|
assert_eq!(result, "Hello");
|
|
}
|
|
|
|
#[test]
|
|
fn preserves_plain_text() {
|
|
assert_eq!(strip_openclaw_user_prefix("Hello world"), "Hello world");
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_usage_from_openclaw_format() {
|
|
let value = json!({
|
|
"message": {
|
|
"usage": {
|
|
"input": 6572,
|
|
"output": 246,
|
|
"cacheRead": 3584,
|
|
"cacheWrite": 100,
|
|
"totalTokens": 10402
|
|
}
|
|
}
|
|
});
|
|
let usage = extract_usage(&value).unwrap();
|
|
assert_eq!(usage.input_tokens, 6572);
|
|
assert_eq!(usage.output_tokens, 246);
|
|
assert_eq!(usage.cache_read_input_tokens, 3584);
|
|
assert_eq!(usage.cache_creation_input_tokens, 100);
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_assistant_content_with_thinking_and_tool_call() {
|
|
let value = json!({
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "thinking", "thinking": "I should read the file"},
|
|
{"type": "text", "text": "[[reply_to_current]] Let me check."},
|
|
{"type": "toolCall", "id": "call_123", "name": "read", "arguments": {"file_path": "/tmp/test"}}
|
|
]
|
|
}
|
|
});
|
|
let blocks = extract_assistant_content(&value);
|
|
assert_eq!(blocks.len(), 3);
|
|
assert!(matches!(&blocks[0], ContentBlock::Thinking { text } if text == "I should read the file"));
|
|
assert!(matches!(&blocks[1], ContentBlock::Text { text } if text == "Let me check."));
|
|
assert!(matches!(&blocks[2], ContentBlock::ToolUse { tool_name, .. } if tool_name == "read"));
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_tool_result_content() {
|
|
let value = json!({
|
|
"message": {
|
|
"role": "toolResult",
|
|
"toolCallId": "call_123",
|
|
"toolName": "read",
|
|
"content": [{"type": "text", "text": "file contents here"}],
|
|
"isError": false
|
|
}
|
|
});
|
|
let blocks = extract_tool_result_content(&value);
|
|
assert_eq!(blocks.len(), 1);
|
|
assert!(matches!(
|
|
&blocks[0],
|
|
ContentBlock::ToolResult { tool_use_id, output_preview, is_error, .. }
|
|
if tool_use_id.as_deref() == Some("call_123")
|
|
&& output_preview.as_deref() == Some("file contents here")
|
|
&& !is_error
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_openclaw_conversation_detail_with_tree() {
|
|
let path = std::env::temp_dir().join(format!(
|
|
"codeg-openclaw-tree-{}.jsonl",
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
let mut file = fs::File::create(&path).expect("create temp jsonl");
|
|
|
|
writeln!(
|
|
file,
|
|
"{}",
|
|
json!({"type":"session","version":3,"id":"test-session","timestamp":"2026-03-17T04:46:14.113Z","cwd":"/tmp/demo"})
|
|
).unwrap();
|
|
|
|
writeln!(
|
|
file,
|
|
"{}",
|
|
json!({"type":"message","id":"u1","parentId":null,"timestamp":"2026-03-17T04:56:22.819Z","message":{"role":"user","content":[{"type":"text","text":"[Tue 2026-03-17 12:56 GMT+8] Hello"}],"timestamp":1773723382812_i64}})
|
|
).unwrap();
|
|
|
|
writeln!(
|
|
file,
|
|
"{}",
|
|
json!({"type":"message","id":"a1","parentId":"u1","timestamp":"2026-03-17T04:56:30.466Z","message":{"role":"assistant","content":[{"type":"text","text":"[[reply_to_current]] Hi there!"}],"model":"gpt-5.4","usage":{"input":100,"output":50,"cacheRead":200,"cacheWrite":0,"totalTokens":350},"stopReason":"stop","timestamp":1773723390466_i64}})
|
|
).unwrap();
|
|
|
|
let detail = OpenClawParser::parse_conversation_detail(&path, "test/test-session", None, None)
|
|
.expect("parse detail");
|
|
fs::remove_file(&path).unwrap();
|
|
|
|
assert_eq!(detail.turns.len(), 2);
|
|
assert!(matches!(detail.turns[0].role, TurnRole::User));
|
|
assert!(matches!(detail.turns[1].role, TurnRole::Assistant));
|
|
|
|
// User text should be cleaned
|
|
assert!(matches!(
|
|
&detail.turns[0].blocks[0],
|
|
ContentBlock::Text { text } if text == "Hello"
|
|
));
|
|
|
|
// Assistant text should strip [[reply_to_current]]
|
|
assert!(matches!(
|
|
&detail.turns[1].blocks[0],
|
|
ContentBlock::Text { text } if text == "Hi there!"
|
|
));
|
|
|
|
// Usage should be mapped correctly
|
|
let usage = detail.turns[1].usage.as_ref().unwrap();
|
|
assert_eq!(usage.input_tokens, 100);
|
|
assert_eq!(usage.output_tokens, 50);
|
|
assert_eq!(usage.cache_read_input_tokens, 200);
|
|
|
|
// Session stats
|
|
let stats = detail.session_stats.unwrap();
|
|
assert!(stats.total_tokens.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn tree_separates_branches() {
|
|
// Simulate a JSONL with a tree:
|
|
// u1 → a1 → u2 → a2 (branch 1: "Hello" conversation)
|
|
// ↘ u3 → a3 (branch 2: "Bye" conversation, forked from a1)
|
|
let path = std::env::temp_dir().join(format!(
|
|
"codeg-openclaw-branches-{}.jsonl",
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
let mut file = fs::File::create(&path).expect("create temp jsonl");
|
|
|
|
writeln!(file, "{}", json!({"type":"session","version":3,"id":"s1","timestamp":"2026-03-17T01:00:00.000Z","cwd":"/tmp"})).unwrap();
|
|
// Shared prefix
|
|
writeln!(file, "{}", json!({"type":"message","id":"u1","parentId":null,"timestamp":"2026-03-17T01:00:01.000Z","message":{"role":"user","content":[{"type":"text","text":"Hello"}]}})).unwrap();
|
|
writeln!(file, "{}", json!({"type":"message","id":"a1","parentId":"u1","timestamp":"2026-03-17T01:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Hi"}]}})).unwrap();
|
|
// Branch 1 continues
|
|
writeln!(file, "{}", json!({"type":"message","id":"u2","parentId":"a1","timestamp":"2026-03-17T01:00:03.000Z","message":{"role":"user","content":[{"type":"text","text":"How are you?"}]}})).unwrap();
|
|
writeln!(file, "{}", json!({"type":"message","id":"a2","parentId":"u2","timestamp":"2026-03-17T01:00:04.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Good!"}]}})).unwrap();
|
|
// Branch 2 forks from a1
|
|
writeln!(file, "{}", json!({"type":"message","id":"u3","parentId":"a1","timestamp":"2026-03-17T01:00:05.000Z","message":{"role":"user","content":[{"type":"text","text":"Bye"}]}})).unwrap();
|
|
writeln!(file, "{}", json!({"type":"message","id":"a3","parentId":"u3","timestamp":"2026-03-17T01:00:06.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Goodbye!"}]}})).unwrap();
|
|
|
|
let tree = JTree::parse(&path).expect("parse tree");
|
|
let branches = tree.conversation_branches();
|
|
fs::remove_file(&path).unwrap();
|
|
|
|
// Should find 2 branches
|
|
assert_eq!(branches.len(), 2);
|
|
|
|
// Branch ending at a2 should contain u2, a2 (forked at a1, u2 is the fork user msg)
|
|
let branch_a2 = branches.iter().find(|(leaf, _)| leaf == "a2").unwrap();
|
|
assert!(branch_a2.1.contains(&"u2".to_string()));
|
|
assert!(branch_a2.1.contains(&"a2".to_string()));
|
|
// Should NOT contain u3
|
|
assert!(!branch_a2.1.contains(&"u3".to_string()));
|
|
|
|
// Branch ending at a3 should contain u3, a3
|
|
let branch_a3 = branches.iter().find(|(leaf, _)| leaf == "a3").unwrap();
|
|
assert!(branch_a3.1.contains(&"u3".to_string()));
|
|
assert!(branch_a3.1.contains(&"a3".to_string()));
|
|
// Should NOT contain u2
|
|
assert!(!branch_a3.1.contains(&"u2".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn extract_session_id_from_filename_works() {
|
|
assert_eq!(
|
|
extract_session_id_from_filename("abc-123.jsonl"),
|
|
Some("abc-123".to_string())
|
|
);
|
|
assert_eq!(
|
|
extract_session_id_from_filename("abc-123.jsonl.reset.2026-03-17T04-46-13.819Z"),
|
|
Some("abc-123".to_string())
|
|
);
|
|
assert_eq!(extract_session_id_from_filename("sessions.json"), None);
|
|
assert_eq!(extract_session_id_from_filename("readme.txt"), None);
|
|
}
|
|
}
|