use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::sidecar::NativeTurn;
use crate::{ChatMessage, InterchangeError as Error, Result};
pub type NodeId = String;
#[derive(Debug, Clone)]
pub struct TreeNode {
pub id: NodeId,
pub parent: Option<NodeId>,
pub children: Vec<NodeId>,
pub message: ChatMessage,
pub label: Option<String>,
pub created_at_ms: i64,
}
#[derive(Serialize, Deserialize)]
struct TreeNodeWire {
id: NodeId,
parent: Option<NodeId>,
#[serde(default)]
children: Vec<NodeId>,
message: NativeTurn,
#[serde(default)]
label: Option<String>,
#[serde(default)]
created_at_ms: i64,
}
impl From<&TreeNode> for TreeNodeWire {
fn from(n: &TreeNode) -> Self {
let message = NativeTurn {
supercode_turn: 1,
ts: crate::sidecar::ms_to_rfc3339(n.created_at_ms),
role: n.message.role,
content: n.message.content.clone(),
content_parts: n.message.content_parts.clone(),
tool_calls: n.message.tool_calls.clone(),
tool_call_id: n.message.tool_call_id.clone(),
name: n.message.name.clone(),
metadata: n.message.metadata.clone(),
};
TreeNodeWire {
id: n.id.clone(),
parent: n.parent.clone(),
children: n.children.clone(),
message,
label: n.label.clone(),
created_at_ms: n.created_at_ms,
}
}
}
impl From<TreeNodeWire> for TreeNode {
fn from(w: TreeNodeWire) -> Self {
TreeNode {
id: w.id,
parent: w.parent,
children: w.children,
message: w.message.into_message(),
label: w.label,
created_at_ms: w.created_at_ms,
}
}
}
impl Serialize for TreeNode {
fn serialize<S: serde::Serializer>(&self, ser: S) -> std::result::Result<S::Ok, S::Error> {
TreeNodeWire::from(self).serialize(ser)
}
}
impl<'de> Deserialize<'de> for TreeNode {
fn deserialize<D: serde::Deserializer<'de>>(de: D) -> std::result::Result<Self, D::Error> {
TreeNodeWire::deserialize(de).map(TreeNode::from)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchSummary {
pub summary: String,
pub node_id: NodeId,
pub branch: String,
#[serde(default)]
pub model_id: Option<String>,
#[serde(default)]
pub created_at_ms: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Branch {
pub name: String,
pub leaf: Option<NodeId>,
#[serde(default)]
pub summary: Option<BranchSummary>,
#[serde(default)]
pub created_at_ms: i64,
}
pub const MAIN_BRANCH: &str = "main";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionTree {
pub nodes: BTreeMap<NodeId, TreeNode>,
pub root: Option<NodeId>,
pub branches: BTreeMap<String, Branch>,
pub active_branch: String,
#[serde(default)]
next_id: u64,
}
impl Default for SessionTree {
fn default() -> Self {
Self::new()
}
}
impl SessionTree {
pub fn new() -> Self {
let mut branches = BTreeMap::new();
branches.insert(
MAIN_BRANCH.to_string(),
Branch {
name: MAIN_BRANCH.to_string(),
leaf: None,
summary: None,
created_at_ms: 0,
},
);
SessionTree {
nodes: BTreeMap::new(),
root: None,
branches,
active_branch: MAIN_BRANCH.to_string(),
next_id: 0,
}
}
pub fn from_linear(messages: &[ChatMessage], created_at_ms: i64) -> Self {
let mut tree = Self::new();
for m in messages {
tree.append_message(m.clone(), created_at_ms);
}
tree
}
fn alloc_id(&mut self) -> NodeId {
loop {
let id = format!("n{}", self.next_id);
self.next_id += 1;
if !self.nodes.contains_key(&id) {
return id;
}
}
}
pub fn node(&self, id: &str) -> Option<&TreeNode> {
self.nodes.get(id)
}
fn require_node(&self, id: &str) -> Result<&TreeNode> {
self.nodes
.get(id)
.ok_or_else(|| Error::Other(format!("session tree has no node `{id}`")))
}
fn require_branch(&self, name: &str) -> Result<&Branch> {
self.branches
.get(name)
.ok_or_else(|| Error::Other(format!("session tree has no branch `{name}`")))
}
pub fn append_message(&mut self, message: ChatMessage, created_at_ms: i64) -> NodeId {
let parent = self
.branches
.get(&self.active_branch)
.and_then(|b| b.leaf.clone());
let id = self.alloc_id();
self.nodes.insert(
id.clone(),
TreeNode {
id: id.clone(),
parent: parent.clone(),
children: Vec::new(),
message,
label: None,
created_at_ms,
},
);
match &parent {
Some(p) => {
if let Some(pn) = self.nodes.get_mut(p) {
pn.children.push(id.clone());
}
}
None => self.root = Some(id.clone()),
}
if let Some(b) = self.branches.get_mut(&self.active_branch) {
b.leaf = Some(id.clone());
}
id
}
fn fresh_branch_name(&self, base: &str) -> String {
if !self.branches.contains_key(base) {
return base.to_string();
}
let mut n = 2u64;
loop {
let candidate = format!("{base}-{n}");
if !self.branches.contains_key(&candidate) {
return candidate;
}
n += 1;
}
}
pub fn rewind(&mut self, node_id: &str, timestamp_ms: i64) -> Result<Option<String>> {
self.require_node(node_id)?;
let old_leaf = self
.branches
.get(&self.active_branch)
.and_then(|b| b.leaf.clone());
let preserved = match &old_leaf {
Some(old) if old != node_id => {
let name = self.fresh_branch_name(&format!("{}-rewound", self.active_branch));
self.branches.insert(
name.clone(),
Branch {
name: name.clone(),
leaf: Some(old.clone()),
summary: None,
created_at_ms: timestamp_ms,
},
);
Some(name)
}
_ => None,
};
if let Some(b) = self.branches.get_mut(&self.active_branch) {
b.leaf = Some(node_id.to_string());
}
Ok(preserved)
}
pub fn branch(
&mut self,
from_node: &str,
name: Option<String>,
timestamp_ms: i64,
) -> Result<String> {
self.require_node(from_node)?;
let name = match name {
Some(n) => {
if self.branches.contains_key(&n) {
return Err(Error::Other(format!(
"session tree already has a branch named `{n}`"
)));
}
n
}
None => self.fresh_branch_name("branch"),
};
self.branches.insert(
name.clone(),
Branch {
name: name.clone(),
leaf: Some(from_node.to_string()),
summary: None,
created_at_ms: timestamp_ms,
},
);
self.active_branch = name.clone();
Ok(name)
}
pub fn switch_branch(&mut self, name: &str) -> Result<()> {
self.require_branch(name)?;
self.active_branch = name.to_string();
Ok(())
}
pub fn label(&mut self, node_id: &str, label: impl Into<String>) -> Result<()> {
let node = self
.nodes
.get_mut(node_id)
.ok_or_else(|| Error::Other(format!("session tree has no node `{node_id}`")))?;
node.label = Some(label.into());
Ok(())
}
pub fn clear_label(&mut self, node_id: &str) -> Result<()> {
let node = self
.nodes
.get_mut(node_id)
.ok_or_else(|| Error::Other(format!("session tree has no node `{node_id}`")))?;
node.label = None;
Ok(())
}
pub fn linear_projection(&self) -> Result<Vec<ChatMessage>> {
self.linear_projection_of(&self.active_branch)
}
pub fn linear_projection_of(&self, branch: &str) -> Result<Vec<ChatMessage>> {
let b = self.require_branch(branch)?;
let Some(mut cursor) = b.leaf.clone() else {
return Ok(Vec::new());
};
let mut chain = Vec::new();
let mut visited = BTreeSet::new();
loop {
if !visited.insert(cursor.clone()) {
return Err(Error::Other(format!(
"session tree branch `{branch}` contains a cycle at node `{cursor}`"
)));
}
let node = self.require_node(&cursor)?;
chain.push(node.message.clone());
match &node.parent {
Some(p) => cursor = p.clone(),
None => break,
}
}
chain.reverse();
Ok(chain)
}
pub fn has_branches(&self) -> bool {
self.branches.len() > 1
}
pub fn summarize_branch(
&mut self,
branch: &str,
summary: impl Into<String>,
model_id: Option<String>,
timestamp_ms: i64,
) -> Result<()> {
let leaf = self.require_branch(branch)?.leaf.clone().ok_or_else(|| {
Error::Other(format!(
"session tree branch `{branch}` has no leaf yet — nothing to summarize"
))
})?;
let b = self
.branches
.get_mut(branch)
.expect("just checked via require_branch");
b.summary = Some(BranchSummary {
summary: summary.into(),
node_id: leaf,
branch: branch.to_string(),
model_id,
created_at_ms: timestamp_ms,
});
Ok(())
}
pub fn render_branch_text(&self, branch: &str) -> Result<String> {
let messages = self.linear_projection_of(branch)?;
let mut out = String::new();
for m in &messages {
let role = match m.role {
crate::message::Role::System => "system",
crate::message::Role::User => "user",
crate::message::Role::Assistant => "assistant",
crate::message::Role::Tool => "tool",
};
out.push_str(role);
out.push_str(": ");
out.push_str(m.content.as_deref().unwrap_or(""));
out.push('\n');
}
Ok(out)
}
pub fn summarize_branch_with(
&mut self,
branch: &str,
summarizer: &dyn BranchSummarizer,
timestamp_ms: i64,
) -> Result<()> {
let text = self.render_branch_text(branch)?;
let turn_count = self.linear_projection_of(branch)?.len();
match summarizer.summarize(&text) {
Ok(summary) => {
self.summarize_branch(
branch,
summary,
Some(summarizer.model_id().to_string()),
timestamp_ms,
)?;
}
Err(_) => {
self.summarize_branch(
branch,
format!("[{turn_count} turn(s), unsummarized]"),
None,
timestamp_ms,
)?;
}
}
Ok(())
}
pub fn splice_for_linear_export(&self) -> Result<(Vec<ChatMessage>, Vec<BranchSummary>)> {
let active = self.linear_projection()?;
let mut summaries = Vec::new();
for (name, b) in &self.branches {
if name == &self.active_branch {
continue;
}
if let Some(s) = &b.summary {
summaries.push(s.clone());
} else {
let (summary_text, node_id) = match &b.leaf {
None => (
format!("[branch `{name}` has no leaf yet — nothing to summarize]"),
String::new(),
),
Some(leaf) => match self.linear_projection_of(name) {
Ok(msgs) => (
format!("[{} turn(s), unsummarized]", msgs.len()),
leaf.clone(),
),
Err(e) => (
format!("[branch `{name}` could not be read, unsummarized: {e}]"),
leaf.clone(),
),
},
};
summaries.push(BranchSummary {
summary: summary_text,
node_id,
branch: name.clone(),
model_id: None,
created_at_ms: b.created_at_ms,
});
}
}
Ok((active, summaries))
}
}
pub trait BranchSummarizer {
fn summarize(&self, branch_text: &str) -> Result<String>;
fn model_id(&self) -> &str;
}
#[cfg(test)]
mod tests {
use super::*;
fn msgs(n: usize) -> Vec<ChatMessage> {
(0..n)
.map(|i| ChatMessage::user(format!("turn {i}")))
.collect()
}
fn content_of(m: &ChatMessage) -> &str {
m.content.as_deref().unwrap_or("")
}
#[test]
fn linear_projection_of_a_from_linear_tree_matches_the_source_messages() {
let source = msgs(5);
let tree = SessionTree::from_linear(&source, 1_700_000_000_000);
let projected = tree.linear_projection().unwrap();
assert_eq!(projected.len(), source.len());
for (p, s) in projected.iter().zip(source.iter()) {
assert_eq!(content_of(p), content_of(s));
}
assert!(!tree.has_branches());
}
#[test]
fn empty_tree_has_empty_linear_projection() {
let tree = SessionTree::new();
assert!(tree.linear_projection().unwrap().is_empty());
assert_eq!(tree.root, None);
}
#[test]
fn append_message_chains_and_advances_the_active_leaf() {
let mut tree = SessionTree::new();
let n0 = tree.append_message(ChatMessage::user("hello"), 1);
let n1 = tree.append_message(ChatMessage::assistant("hi"), 2);
assert_eq!(tree.root, Some(n0.clone()));
assert_eq!(tree.branches[MAIN_BRANCH].leaf, Some(n1.clone()));
assert_eq!(tree.node(&n1).unwrap().parent, Some(n0.clone()));
assert_eq!(tree.node(&n0).unwrap().children, vec![n1]);
}
#[test]
fn rewind_to_unknown_node_errors_not_corrupts() {
let mut tree = SessionTree::from_linear(&msgs(3), 1);
let before = tree.clone_for_test();
let err = tree.rewind("does-not-exist", 2).unwrap_err();
assert!(err.to_string().contains("does-not-exist"));
assert_eq!(
tree.branches[MAIN_BRANCH].leaf,
before.branches[MAIN_BRANCH].leaf
);
assert_eq!(tree.nodes.len(), before.nodes.len());
}
#[test]
fn rewind_preserves_the_rewound_past_as_a_recoverable_sibling_branch() {
let mut tree = SessionTree::from_linear(&msgs(4), 1); let n1 = "n1".to_string();
let old_leaf = tree.branches[MAIN_BRANCH].leaf.clone().unwrap();
assert_eq!(old_leaf, "n3");
let preserved = tree.rewind(&n1, 100).unwrap().expect("moved the pointer");
assert_eq!(tree.branches[MAIN_BRANCH].leaf, Some(n1.clone()));
assert!(tree.node("n2").is_some());
assert!(tree.node("n3").is_some());
assert_eq!(tree.branches[&preserved].leaf, Some(old_leaf));
let recovered = tree.linear_projection_of(&preserved).unwrap();
assert_eq!(recovered.len(), 4);
assert_eq!(content_of(&recovered[3]), "turn 3");
let active = tree.linear_projection().unwrap();
assert_eq!(active.len(), 2);
assert_eq!(content_of(&active[1]), "turn 1");
}
#[test]
fn rewind_to_the_current_leaf_is_a_no_op_and_preserves_nothing_new() {
let mut tree = SessionTree::from_linear(&msgs(2), 1);
let leaf = tree.branches[MAIN_BRANCH].leaf.clone().unwrap();
let branch_count_before = tree.branches.len();
let preserved = tree.rewind(&leaf, 2).unwrap();
assert_eq!(preserved, None);
assert_eq!(tree.branches.len(), branch_count_before);
}
#[test]
fn appending_after_rewind_forks_a_new_sibling_child() {
let mut tree = SessionTree::from_linear(&msgs(3), 1); let n0 = "n0".to_string();
tree.rewind(&n0, 10).unwrap();
let new_child = tree.append_message(ChatMessage::user("alt turn 1"), 11);
let n0_children = &tree.node(&n0).unwrap().children;
assert_eq!(n0_children.len(), 2);
assert!(n0_children.contains(&"n1".to_string()));
assert!(n0_children.contains(&new_child));
let active = tree.linear_projection().unwrap();
assert_eq!(active.len(), 2);
assert_eq!(content_of(&active[1]), "alt turn 1");
}
#[test]
fn no_api_can_create_a_cycle_linear_projection_of_a_hand_edited_cycle_errors() {
let mut tree = SessionTree::from_linear(&msgs(2), 1);
tree.nodes.get_mut("n0").unwrap().parent = Some("n1".to_string());
let err = tree.linear_projection_of(MAIN_BRANCH).unwrap_err();
assert!(err.to_string().contains("cycle"));
}
#[test]
fn branch_forks_at_a_node_and_switches_active() {
let mut tree = SessionTree::from_linear(&msgs(3), 1); let name = tree.branch("n1", Some("alt".to_string()), 5).unwrap();
assert_eq!(name, "alt");
assert_eq!(tree.active_branch, "alt");
assert_eq!(tree.branches["alt"].leaf, Some("n1".to_string()));
tree.append_message(ChatMessage::user("alt turn"), 6);
let alt_projection = tree.linear_projection().unwrap();
assert_eq!(alt_projection.len(), 3);
assert_eq!(content_of(&alt_projection[2]), "alt turn");
let main_projection = tree.linear_projection_of(MAIN_BRANCH).unwrap();
assert_eq!(main_projection.len(), 3);
assert_eq!(content_of(&main_projection[2]), "turn 2");
}
#[test]
fn branch_auto_names_when_no_name_given() {
let mut tree = SessionTree::from_linear(&msgs(2), 1);
let a = tree.branch("n0", None, 1).unwrap();
tree.switch_branch(MAIN_BRANCH).unwrap();
let b = tree.branch("n0", None, 2).unwrap();
assert_ne!(a, b);
}
#[test]
fn branch_with_duplicate_explicit_name_errors() {
let mut tree = SessionTree::from_linear(&msgs(2), 1);
tree.branch("n0", Some("x".to_string()), 1).unwrap();
tree.switch_branch(MAIN_BRANCH).unwrap();
let err = tree.branch("n0", Some("x".to_string()), 2).unwrap_err();
assert!(err.to_string().contains("x"));
}
#[test]
fn branch_at_unknown_node_errors() {
let mut tree = SessionTree::from_linear(&msgs(1), 1);
assert!(tree.branch("ghost", None, 1).is_err());
}
#[test]
fn switch_branch_to_unknown_name_errors() {
let mut tree = SessionTree::from_linear(&msgs(1), 1);
assert!(tree.switch_branch("ghost").is_err());
}
#[test]
fn missing_next_id_field_no_longer_causes_a_silent_node_overwrite() {
let tree = SessionTree::from_linear(
&[ChatMessage::user("original n0"), ChatMessage::user("n1")],
1,
);
let mut v: serde_json::Value = serde_json::to_value(&tree).unwrap();
assert!(v.get("next_id").is_some());
v.as_object_mut().unwrap().remove("next_id");
let mut reloaded: SessionTree = serde_json::from_value(v).unwrap();
let id = reloaded.append_message(ChatMessage::user("usurper"), 2);
assert_ne!(id, "n0");
assert_ne!(id, "n1");
assert_eq!(
reloaded.node("n0").unwrap().message.content.as_deref(),
Some("original n0")
);
assert_eq!(
reloaded.node("n1").unwrap().message.content.as_deref(),
Some("n1")
);
assert_eq!(
reloaded.node(&id).unwrap().message.content.as_deref(),
Some("usurper")
);
}
#[test]
fn alloc_id_skips_past_several_hand_planted_collisions_in_a_row() {
let mut tree = SessionTree::new();
for i in 5..8 {
tree.nodes.insert(
format!("n{i}"),
TreeNode {
id: format!("n{i}"),
parent: None,
children: Vec::new(),
message: ChatMessage::user(format!("planted {i}")),
label: None,
created_at_ms: 0,
},
);
}
tree.next_id = 5; let id = tree.append_message(ChatMessage::user("first real append"), 1);
assert_eq!(id, "n8"); for i in 5..8 {
assert_eq!(
tree.node(&format!("n{i}")).unwrap().message.content,
Some(format!("planted {i}"))
);
}
}
#[test]
fn label_and_clear_label_round_trip() {
let mut tree = SessionTree::from_linear(&msgs(2), 1);
tree.label("n0", "checkpoint-a").unwrap();
assert_eq!(
tree.node("n0").unwrap().label.as_deref(),
Some("checkpoint-a")
);
tree.clear_label("n0").unwrap();
assert_eq!(tree.node("n0").unwrap().label, None);
}
#[test]
fn label_unknown_node_errors() {
let mut tree = SessionTree::from_linear(&msgs(1), 1);
assert!(tree.label("ghost", "x").is_err());
}
struct FakeSummarizer(&'static str);
impl BranchSummarizer for FakeSummarizer {
fn summarize(&self, _branch_text: &str) -> Result<String> {
Ok(format!("summary via {}", self.0))
}
fn model_id(&self) -> &str {
self.0
}
}
struct FailingSummarizer;
impl BranchSummarizer for FailingSummarizer {
fn summarize(&self, _branch_text: &str) -> Result<String> {
Err(Error::Other("boom".to_string()))
}
fn model_id(&self) -> &str {
"unused"
}
}
#[test]
fn summarize_branch_with_records_model_generated_summary() {
let mut tree = SessionTree::from_linear(&msgs(3), 1);
tree.branch("n0", Some("off-path".to_string()), 5).unwrap();
tree.switch_branch(MAIN_BRANCH).unwrap();
tree.summarize_branch_with("off-path", &FakeSummarizer("haiku-test"), 9)
.unwrap();
let s = tree.branches["off-path"].summary.as_ref().unwrap();
assert_eq!(s.summary, "summary via haiku-test");
assert_eq!(s.model_id.as_deref(), Some("haiku-test"));
assert_eq!(s.branch, "off-path");
}
#[test]
fn summarize_branch_with_never_fails_on_summarizer_error() {
let mut tree = SessionTree::from_linear(&msgs(3), 1);
tree.branch("n0", Some("off-path".to_string()), 5).unwrap();
tree.switch_branch(MAIN_BRANCH).unwrap();
tree.summarize_branch_with("off-path", &FailingSummarizer, 9)
.unwrap();
let s = tree.branches["off-path"].summary.as_ref().unwrap();
assert!(s.summary.contains("unsummarized"));
assert_eq!(s.model_id, None);
}
#[test]
fn summarize_branch_on_a_leafless_branch_errors_instead_of_recording_an_empty_node_id() {
let mut tree = SessionTree::new();
let err = tree
.summarize_branch(MAIN_BRANCH, "premature summary", None, 1)
.unwrap_err();
assert!(err.to_string().contains(MAIN_BRANCH));
assert!(tree.branches[MAIN_BRANCH].summary.is_none());
}
#[test]
fn splice_for_linear_export_returns_active_path_and_summarizes_off_path_branches() {
let mut tree = SessionTree::from_linear(&msgs(2), 1); tree.branch("n0", Some("side-quest".to_string()), 5)
.unwrap();
tree.append_message(ChatMessage::user("side turn"), 6);
tree.switch_branch(MAIN_BRANCH).unwrap();
let before_node_count = tree.nodes.len();
let (active, summaries) = tree.splice_for_linear_export().unwrap();
assert_eq!(active.len(), 2);
assert_eq!(content_of(&active[1]), "turn 1");
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].branch, "side-quest");
assert!(summaries[0].summary.contains("unsummarized"));
assert_eq!(tree.nodes.len(), before_node_count);
let recovered = tree.linear_projection_of(&summaries[0].branch).unwrap();
assert_eq!(recovered.len(), 2);
assert_eq!(content_of(&recovered[1]), "side turn");
}
#[test]
fn splice_for_linear_export_reuses_an_explicit_summary_if_already_set() {
let mut tree = SessionTree::from_linear(&msgs(1), 1);
tree.branch("n0", Some("side".to_string()), 5).unwrap();
tree.summarize_branch("side", "hand-written summary", None, 6)
.unwrap();
tree.switch_branch(MAIN_BRANCH).unwrap();
let (_active, summaries) = tree.splice_for_linear_export().unwrap();
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].summary, "hand-written summary");
}
#[test]
fn a_degenerate_single_path_tree_splices_to_the_whole_transcript_with_no_summaries() {
let tree = SessionTree::from_linear(&msgs(3), 1);
let (active, summaries) = tree.splice_for_linear_export().unwrap();
assert_eq!(active.len(), 3);
assert!(summaries.is_empty());
}
#[test]
fn splice_for_linear_export_surfaces_a_corrupt_off_path_branch_instead_of_masking_it_as_empty()
{
let mut tree = SessionTree::from_linear(&msgs(2), 1); tree.branch("n0", Some("side-quest".to_string()), 5)
.unwrap();
tree.append_message(ChatMessage::user("side turn"), 6);
tree.switch_branch(MAIN_BRANCH).unwrap();
let side_leaf = tree.branches["side-quest"].leaf.clone().unwrap();
tree.nodes.get_mut(&side_leaf).unwrap().parent = Some(side_leaf.clone());
let (active, summaries) = tree.splice_for_linear_export().unwrap();
assert_eq!(active.len(), 2);
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].branch, "side-quest");
assert!(!summaries[0].summary.contains("0 turn"));
assert!(
summaries[0].summary.contains("could not be read")
|| summaries[0].summary.contains("corrupt")
);
}
#[test]
fn splice_for_linear_export_on_a_leafless_off_path_branch_does_not_fabricate_a_node_id() {
let mut tree = SessionTree::from_linear(&msgs(1), 1);
tree.branches.insert(
"empty-branch".to_string(),
Branch {
name: "empty-branch".to_string(),
leaf: None,
summary: None,
created_at_ms: 0,
},
);
let (_active, summaries) = tree.splice_for_linear_export().unwrap();
let s = summaries
.iter()
.find(|s| s.branch == "empty-branch")
.unwrap();
assert_eq!(s.node_id, "");
assert!(s.summary.contains("no leaf"));
}
impl SessionTree {
fn clone_for_test(&self) -> Self {
self.clone()
}
}
}