use std::{fs, path::PathBuf};
use rho_providers::model::{ContentBlock, Message};
use rho_sdk::{CompactionState, Revision, SessionId, SessionSnapshot};
use rho_tools::tool::{ToolCall, ToolResult};
use tempfile::TempDir;
use super::persistence::{
session_dir_in_root, summarize_session_file, SessionEntry, SESSION_TRANSCRIPT_FILE_NAME,
};
use super::tree::{NodeId, SessionNode, SessionNodeKind, StoredStateTransition};
use super::*;
fn write_entries(path: &std::path::Path, entries: &[SessionEntry]) {
let contents = entries
.iter()
.map(|entry| serde_json::to_string(entry).unwrap())
.collect::<Vec<_>>()
.join("\n")
+ "\n";
fs::write(path, contents).unwrap();
}
fn fixture_path(root: &TempDir, cwd: &TempDir, id: &str, created_at: u64) -> PathBuf {
let dir = session_dir_in_root(root.path(), cwd.path());
fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("{created_at}_{id}.jsonl"));
path
}
#[test]
fn pure_legacy_replace_history_uses_display_not_model_replacement() {
let root = TempDir::new().unwrap();
let cwd = TempDir::new().unwrap();
let id = "legacy-replace";
let path = fixture_path(&root, &cwd, id, 100);
write_entries(
&path,
&[
SessionEntry::Session {
version: 1,
id: id.into(),
timestamp: "100".into(),
cwd: cwd.path().to_path_buf(),
agent_id: None,
agent_fingerprint: None,
},
SessionEntry::Message {
timestamp: "101".into(),
message: Message::user_text("model prompt"),
display_message: Some(Box::new(Message::user_text("/goal model prompt"))),
},
SessionEntry::Message {
timestamp: "102".into(),
message: Message::assistant_text("answer"),
display_message: None,
},
SessionEntry::ReplaceHistory {
timestamp: "103".into(),
messages: vec![Message::user_text("compacted model summary")],
},
SessionEntry::Message {
timestamp: "104".into(),
message: Message::assistant_text("after"),
display_message: None,
},
],
);
let summary = summarize_session_file(&path, cwd.path()).unwrap().summary;
assert_eq!(summary.message_count, 3);
assert_eq!(
summary.first_user_message.as_deref(),
Some("/goal model prompt")
);
assert_eq!(
summary.last_user_message.as_deref(),
Some("/goal model prompt")
);
assert_eq!(summary.updated_at, 104);
assert_eq!(summary.created_at, 100);
}
#[test]
fn summarize_prefers_display_message_over_model_message() {
let root = TempDir::new().unwrap();
let cwd = TempDir::new().unwrap();
let id = "display-pref";
let path = fixture_path(&root, &cwd, id, 200);
write_entries(
&path,
&[
SessionEntry::Session {
version: 3,
id: id.into(),
timestamp: "200".into(),
cwd: cwd.path().to_path_buf(),
agent_id: None,
agent_fingerprint: None,
},
SessionEntry::Message {
timestamp: "201".into(),
message: Message::user_text("hidden model text"),
display_message: Some(Box::new(Message::user_text("visible display text"))),
},
],
);
let summary = summarize_session_file(&path, cwd.path()).unwrap().summary;
assert_eq!(summary.message_count, 1);
assert_eq!(
summary.first_user_message.as_deref(),
Some("visible display text")
);
}
#[test]
fn tree_summary_follows_active_leaf_after_branch_switch() {
let root = TempDir::new().unwrap();
let cwd = TempDir::new().unwrap();
let session = Session::create_in_root(root.path(), cwd.path()).unwrap();
let session_id = SessionId::from_string(session.id().to_owned()).unwrap();
let root_snapshot = SessionSnapshot::new(
session_id.clone(),
Revision::from_u64(1),
vec![Message::user_text("root")],
ModelIdentity::new("provider", "api", "model"),
CompactionState::default(),
);
session
.save_snapshot(&root_snapshot, root_snapshot.history())
.unwrap();
let root_id = session
.session_tree()
.unwrap()
.active_leaf_id()
.unwrap()
.clone();
let left = SessionSnapshot::new(
session_id.clone(),
Revision::from_u64(2),
vec![Message::user_text("root"), Message::assistant_text("left")],
ModelIdentity::new("provider", "api", "model"),
CompactionState::default(),
);
session.save_snapshot(&left, &left.history()[1..]).unwrap();
session.set_leaf(&root_id).unwrap();
let right = SessionSnapshot::new(
session_id,
Revision::from_u64(2),
vec![
Message::user_text("root"),
Message::assistant_text("right branch"),
],
ModelIdentity::new("provider", "api", "model"),
CompactionState::default(),
);
session
.save_snapshot(&right, &right.history()[1..])
.unwrap();
let record = summarize_session_file(session.path(), cwd.path()).unwrap();
assert_eq!(record.summary.message_count, 2);
assert_eq!(record.summary.first_user_message.as_deref(), Some("root"));
assert_eq!(record.branch_count, 1);
assert_eq!(record.node_count, 3);
let tree = session.session_tree().unwrap();
assert_eq!(
tree.active_state().unwrap().model.last(),
Some(&Message::assistant_text("right branch"))
);
}
#[test]
fn summarize_drops_incomplete_tool_tail_from_active_display() {
let root = TempDir::new().unwrap();
let cwd = TempDir::new().unwrap();
let id = "tool-tail";
let path = fixture_path(&root, &cwd, id, 300);
write_entries(
&path,
&[
SessionEntry::Session {
version: 3,
id: id.into(),
timestamp: "300".into(),
cwd: cwd.path().to_path_buf(),
agent_id: None,
agent_fingerprint: None,
},
SessionEntry::Message {
timestamp: "301".into(),
message: Message::user_text("please run"),
display_message: None,
},
SessionEntry::Message {
timestamp: "302".into(),
message: Message::Assistant(vec![ContentBlock::ToolCall(ToolCall {
id: "call-1".into(),
name: "bash".into(),
arguments: serde_json::json!({}),
})]),
display_message: None,
},
],
);
let summary = summarize_session_file(&path, cwd.path()).unwrap().summary;
assert_eq!(summary.message_count, 1);
assert_eq!(summary.first_user_message.as_deref(), Some("please run"));
assert_eq!(summary.last_user_message.as_deref(), Some("please run"));
}
#[test]
fn summarize_keeps_complete_tool_turn() {
let root = TempDir::new().unwrap();
let cwd = TempDir::new().unwrap();
let id = "tool-complete";
let path = fixture_path(&root, &cwd, id, 400);
write_entries(
&path,
&[
SessionEntry::Session {
version: 3,
id: id.into(),
timestamp: "400".into(),
cwd: cwd.path().to_path_buf(),
agent_id: None,
agent_fingerprint: None,
},
SessionEntry::Message {
timestamp: "401".into(),
message: Message::user_text("run it"),
display_message: None,
},
SessionEntry::Message {
timestamp: "402".into(),
message: Message::Assistant(vec![ContentBlock::ToolCall(ToolCall {
id: "call-1".into(),
name: "bash".into(),
arguments: serde_json::json!({}),
})]),
display_message: None,
},
SessionEntry::Message {
timestamp: "403".into(),
message: Message::ToolResult(ToolResult {
id: "call-1".into(),
ok: true,
content: "done".into(),
}),
display_message: None,
},
],
);
let summary = summarize_session_file(&path, cwd.path()).unwrap().summary;
assert_eq!(summary.message_count, 3);
assert_eq!(summary.first_user_message.as_deref(), Some("run it"));
}
#[test]
fn v4_tree_session_summary_matches_active_display() {
let root = TempDir::new().unwrap();
let cwd = TempDir::new().unwrap();
let dir = session_dir_in_root(root.path(), cwd.path());
fs::create_dir_all(&dir).unwrap();
let session_dir = dir.join("500_upgraded-tree");
fs::create_dir_all(&session_dir).unwrap();
let path = session_dir.join(SESSION_TRANSCRIPT_FILE_NAME);
let session_id = "upgraded-tree";
let node_id = NodeId::from_string("leaf-1").unwrap();
let snapshot = SessionSnapshot::new(
SessionId::from_string(session_id).unwrap(),
Revision::from_u64(1),
vec![Message::user_text("model only")],
ModelIdentity::new("provider", "api", "model"),
CompactionState::default(),
);
write_entries(
&path,
&[
SessionEntry::Session {
version: 4,
id: session_id.into(),
timestamp: "500".into(),
cwd: cwd.path().to_path_buf(),
agent_id: None,
agent_fingerprint: None,
},
SessionEntry::Node {
node: SessionNode {
id: node_id,
parent_id: None,
timestamp: "501".into(),
kind: SessionNodeKind::Commit,
compaction_facts: None,
transition: StoredStateTransition::Snapshot {
snapshot: Box::new(snapshot),
},
display_messages: vec![super::persistence::StoredDisplayMessage {
timestamp: "501".into(),
message: Message::user_text("tree display prompt"),
}],
},
},
],
);
let record = summarize_session_file(&path, cwd.path()).unwrap();
assert_eq!(record.summary.message_count, 1);
assert_eq!(
record.summary.first_user_message.as_deref(),
Some("tree display prompt")
);
assert_eq!(record.node_count, 1);
assert_eq!(record.effective_format_version, 4);
}