use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum SessionEntryType {
Message(MessageEntry),
BranchSummary(BranchSummaryEntry),
Compaction(CompactionEntry),
Label(LabelEntry),
SessionInfo(SessionInfoEntry),
Custom(CustomEntry),
CustomMessage(CustomMessageEntry),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageEntry {
pub id: Uuid,
pub parent_id: Option<Uuid>,
pub timestamp: i64,
pub role: MessageRole,
pub content: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MessageRole {
User,
Assistant,
System,
Tool,
ToolResult,
Custom,
}
impl MessageRole {
pub fn is_user(&self) -> bool {
matches!(self, MessageRole::User)
}
pub fn is_assistant(&self) -> bool {
matches!(self, MessageRole::Assistant)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchSummaryEntry {
pub id: Uuid,
pub parent_id: Option<Uuid>,
pub timestamp: i64,
pub from_id: Uuid,
pub summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<BranchSummaryDetails>,
#[serde(skip_serializing_if = "Option::is_none")]
pub from_hook: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchSummaryDetails {
pub read_files: Vec<String>,
pub modified_files: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompactionEntry {
pub id: Uuid,
pub parent_id: Option<Uuid>,
pub timestamp: i64,
pub summary: String,
pub first_kept_entry_id: Uuid,
pub tokens_before: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub from_hook: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LabelEntry {
pub id: Uuid,
pub parent_id: Option<Uuid>,
pub timestamp: i64,
pub target_id: Uuid,
pub label: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionInfoEntry {
pub id: Uuid,
pub parent_id: Option<Uuid>,
pub timestamp: i64,
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomEntry {
pub id: Uuid,
pub parent_id: Option<Uuid>,
pub timestamp: i64,
pub custom_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomMessageEntry {
pub id: Uuid,
pub parent_id: Option<Uuid>,
pub timestamp: i64,
pub custom_type: String,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
pub display: bool,
}
#[derive(Debug, Clone)]
pub struct CollectEntriesResult {
pub entries: Vec<SessionEntryType>,
pub common_ancestor_id: Option<Uuid>,
}
#[derive(Debug, Clone, Default)]
pub struct NavigationOptions {
pub summarize: bool,
pub custom_instructions: Option<String>,
pub replace_instructions: bool,
pub label: Option<String>,
}
#[derive(Debug, Clone)]
pub struct NavigationResult {
pub editor_text: Option<String>,
pub cancelled: bool,
pub aborted: bool,
pub summary_entry_id: Option<Uuid>,
}
#[derive(Debug, Clone)]
pub struct TreePreparation {
pub target_id: Uuid,
pub old_leaf_id: Option<Uuid>,
pub common_ancestor_id: Option<Uuid>,
pub entries_to_summarize: Vec<SessionEntryType>,
pub user_wants_summary: bool,
pub custom_instructions: Option<String>,
pub replace_instructions: bool,
pub label: Option<String>,
}
pub trait Summarizer: Send + Sync {
fn summarize(
&self,
entries: &[SessionEntryType],
custom_instructions: Option<&str>,
replace_instructions: bool,
) -> impl std::future::Future<Output = Result<BranchSummaryResult, SummarizationError>>
+ Send
+ 'static;
}
#[derive(Debug, Clone)]
pub struct BranchSummaryResult {
pub summary: Option<String>,
pub read_files: Vec<String>,
pub modified_files: Vec<String>,
pub aborted: bool,
pub error: Option<String>,
}
#[derive(Debug, Clone)]
pub enum SummarizationError {
NoModel,
Aborted,
Failed(String),
}
impl std::fmt::Display for SummarizationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SummarizationError::NoModel => write!(f, "No model available for summarization"),
SummarizationError::Aborted => write!(f, "Summarization was aborted"),
SummarizationError::Failed(msg) => write!(f, "Summarization failed: {}", msg),
}
}
}
#[derive(Debug, Clone)]
pub struct BeforeTreeHookResult {
pub cancel: bool,
pub summary: Option<ExtensionSummary>,
pub custom_instructions: Option<String>,
pub replace_instructions: Option<bool>,
pub label: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ExtensionSummary {
pub summary: String,
pub details: Option<serde_json::Value>,
}
pub struct SessionNavigator {
entries_by_id: HashMap<Uuid, SessionEntryType>,
labels_by_id: HashMap<Uuid, String>,
label_timestamps_by_id: HashMap<Uuid, i64>,
leaf_id: Option<Uuid>,
}
impl SessionNavigator {
pub fn new() -> Self {
Self {
entries_by_id: HashMap::new(),
labels_by_id: HashMap::new(),
label_timestamps_by_id: HashMap::new(),
leaf_id: None,
}
}
pub fn from_entries(entries: Vec<SessionEntryType>, leaf_id: Option<Uuid>) -> Self {
let mut entries_by_id = HashMap::new();
for entry in &entries {
let id = Self::entry_id(entry);
entries_by_id.insert(id, entry.clone());
}
Self {
entries_by_id,
labels_by_id: HashMap::new(),
label_timestamps_by_id: HashMap::new(),
leaf_id,
}
}
pub fn get_leaf_id(&self) -> Option<Uuid> {
self.leaf_id
}
pub fn get_entry(&self, id: Uuid) -> Option<&SessionEntryType> {
self.entries_by_id.get(&id)
}
pub fn get_label(&self, id: Uuid) -> Option<&str> {
self.labels_by_id.get(&id).map(|s| s.as_str())
}
pub fn get_entries(&self) -> Vec<&SessionEntryType> {
self.entries_by_id.values().collect()
}
pub fn get_branch(&self, from_id: Option<Uuid>) -> Vec<&SessionEntryType> {
let mut path = Vec::new();
let start_id = from_id.or(self.leaf_id);
let mut current_id = start_id;
while let Some(id) = current_id {
if let Some(entry) = self.entries_by_id.get(&id) {
path.insert(0, entry);
current_id = Self::entry_parent_id(entry);
} else {
break;
}
}
path
}
pub fn get_children(&self, parent_id: Uuid) -> Vec<&SessionEntryType> {
self.entries_by_id
.values()
.filter(|entry| Self::entry_parent_id(entry) == Some(parent_id))
.collect()
}
pub fn collect_entries_for_branch_summary(
&self,
old_leaf_id: Option<Uuid>,
target_id: Uuid,
) -> CollectEntriesResult {
let old_leaf_id = match old_leaf_id {
Some(id) => id,
None => {
return CollectEntriesResult {
entries: Vec::new(),
common_ancestor_id: None,
};
}
};
let old_path_ids: HashSet<Uuid> = self
.get_branch(Some(old_leaf_id))
.iter()
.map(|e| Self::entry_id(e))
.collect();
let target_path = self.get_branch(Some(target_id));
let mut common_ancestor_id: Option<Uuid> = None;
for entry in target_path.iter().rev() {
let id = Self::entry_id(entry);
if old_path_ids.contains(&id) {
common_ancestor_id = Some(id);
break;
}
}
let mut entries: Vec<&SessionEntryType> = Vec::new();
let mut current_id: Option<Uuid> = Some(old_leaf_id);
while let Some(id) = current_id {
if current_id == common_ancestor_id {
break;
}
if let Some(entry) = self.entries_by_id.get(&id) {
entries.push(entry);
current_id = Self::entry_parent_id(entry);
} else {
break;
}
}
entries.reverse();
CollectEntriesResult {
entries: entries.into_iter().cloned().collect(),
common_ancestor_id,
}
}
pub fn navigate_tree<N: Summarizer + ?Sized>(
&mut self,
target_id: Uuid,
options: NavigationOptions,
summarizer: Option<&N>,
extension_hook: Option<&dyn Fn(TreePreparation) -> BeforeTreeHookResult>,
) -> NavigationResult {
let old_leaf_id = self.leaf_id;
if Some(target_id) == old_leaf_id {
return NavigationResult {
editor_text: None,
cancelled: false,
aborted: false,
summary_entry_id: None,
};
}
let target_entry = match self.entries_by_id.get(&target_id) {
Some(e) => e,
None => {
return NavigationResult {
editor_text: None,
cancelled: true,
aborted: false,
summary_entry_id: None,
};
}
};
let collection = self.collect_entries_for_branch_summary(old_leaf_id, target_id);
let mut custom_instructions = options.custom_instructions.clone();
let mut replace_instructions = options.replace_instructions;
let mut label = options.label.clone();
let preparation = TreePreparation {
target_id,
old_leaf_id,
common_ancestor_id: collection.common_ancestor_id,
entries_to_summarize: collection.entries.clone(),
user_wants_summary: options.summarize,
custom_instructions: custom_instructions.clone(),
replace_instructions,
label: label.clone(),
};
let mut extension_summary: Option<ExtensionSummary> = None;
let mut from_extension = false;
if let Some(hook) = extension_hook {
let result = hook(preparation);
if result.cancel {
return NavigationResult {
editor_text: None,
cancelled: true,
aborted: false,
summary_entry_id: None,
};
}
if let Some(ext_sum) = result.summary {
extension_summary = Some(ext_sum);
from_extension = true;
}
if let Some(ci) = result.custom_instructions {
custom_instructions = Some(ci);
}
if let Some(ri) = result.replace_instructions {
replace_instructions = ri;
}
if let Some(l) = result.label {
label = Some(l);
}
}
let mut summary_text: Option<String> = None;
let mut summary_details: Option<BranchSummaryDetails> = None;
if options.summarize && !collection.entries.is_empty() && extension_summary.is_none() {
if let Some(summarizer) = summarizer {
let entries_clone: Vec<SessionEntryType> = collection.entries.clone();
let custom_clone = custom_instructions.clone();
let result = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(summarizer.summarize(
&entries_clone,
custom_clone.as_deref(),
replace_instructions,
))
});
match result {
Ok(summary_result) => {
if summary_result.aborted {
return NavigationResult {
editor_text: None,
cancelled: true,
aborted: true,
summary_entry_id: None,
};
}
if let Some(err) = summary_result.error {
tracing::warn!("Summarization failed: {}", err);
}
summary_text = summary_result.summary;
if !summary_result.read_files.is_empty()
|| !summary_result.modified_files.is_empty()
{
summary_details = Some(BranchSummaryDetails {
read_files: summary_result.read_files,
modified_files: summary_result.modified_files,
});
}
}
Err(e) => {
tracing::warn!("Summarization error: {:?}", e);
}
}
}
} else if let Some(ext_sum) = extension_summary {
summary_text = Some(ext_sum.summary);
summary_details = ext_sum.details.and_then(|d| serde_json::from_value(d).ok());
}
let (new_leaf_id, editor_text) = Self::determine_leaf_and_editor(target_entry);
let has_summary = summary_text.is_some();
let summary_entry_id = if let Some(text) = summary_text {
let summary_id =
self.branch_with_summary(new_leaf_id, text, summary_details, from_extension);
if let Some(l) = &label {
self.append_label_change(summary_id, Some(l.clone()));
}
Some(summary_id)
} else if new_leaf_id.is_none() {
self.reset_leaf();
None
} else {
if let Some(id) = new_leaf_id {
self.branch(id);
} else {
self.reset_leaf();
}
None
};
let has_label = label.is_some();
if has_label && !has_summary {
self.append_label_change(target_id, label);
}
NavigationResult {
editor_text,
cancelled: false,
aborted: false,
summary_entry_id,
}
}
fn determine_leaf_and_editor(entry: &SessionEntryType) -> (Option<Uuid>, Option<String>) {
match entry {
SessionEntryType::Message(msg) if msg.role.is_user() => {
let editor_text = if msg.content.is_empty() {
None
} else {
Some(msg.content.clone())
};
(msg.parent_id, editor_text)
}
SessionEntryType::CustomMessage(custom) => {
let editor_text = if custom.content.is_empty() {
None
} else {
Some(custom.content.clone())
};
(custom.parent_id, editor_text)
}
_ => {
let leaf_id = Self::entry_id(entry);
(Some(leaf_id), None)
}
}
}
pub fn branch(&mut self, branch_from_id: Uuid) {
if !self.entries_by_id.contains_key(&branch_from_id) {
return;
}
self.leaf_id = Some(branch_from_id);
}
pub fn reset_leaf(&mut self) {
self.leaf_id = None;
}
pub fn branch_with_summary(
&mut self,
branch_from_id: Option<Uuid>,
summary: String,
details: Option<BranchSummaryDetails>,
from_hook: bool,
) -> Uuid {
self.leaf_id = branch_from_id;
let summary_id = Uuid::new_v4();
let entry = SessionEntryType::BranchSummary(BranchSummaryEntry {
id: summary_id,
parent_id: branch_from_id,
timestamp: Utc::now().timestamp_millis(),
from_id: branch_from_id.unwrap_or(Uuid::nil()),
summary,
details,
from_hook: Some(from_hook),
});
self.entries_by_id.insert(summary_id, entry.clone());
summary_id
}
pub fn append_label_change(&mut self, target_id: Uuid, label: Option<String>) -> Uuid {
if !self.entries_by_id.contains_key(&target_id) {
return Uuid::nil();
}
let label_id = Uuid::new_v4();
let entry = SessionEntryType::Label(LabelEntry {
id: label_id,
parent_id: self.leaf_id,
timestamp: Utc::now().timestamp_millis(),
target_id,
label: label.clone(),
});
self.entries_by_id.insert(label_id, entry);
if let Some(l) = label {
self.labels_by_id.insert(target_id, l);
self.label_timestamps_by_id
.insert(target_id, Utc::now().timestamp_millis());
} else {
self.labels_by_id.remove(&target_id);
self.label_timestamps_by_id.remove(&target_id);
}
label_id
}
pub fn add_entry(&mut self, entry: SessionEntryType) {
let id = Self::entry_id(&entry);
self.entries_by_id.insert(id, entry);
}
pub fn get_label_timestamp(&self, id: Uuid) -> Option<i64> {
self.label_timestamps_by_id.get(&id).copied()
}
fn entry_id(entry: &SessionEntryType) -> Uuid {
match entry {
SessionEntryType::Message(e) => e.id,
SessionEntryType::BranchSummary(e) => e.id,
SessionEntryType::Compaction(e) => e.id,
SessionEntryType::Label(e) => e.id,
SessionEntryType::SessionInfo(e) => e.id,
SessionEntryType::Custom(e) => e.id,
SessionEntryType::CustomMessage(e) => e.id,
}
}
fn entry_parent_id(entry: &SessionEntryType) -> Option<Uuid> {
match entry {
SessionEntryType::Message(e) => e.parent_id,
SessionEntryType::BranchSummary(e) => e.parent_id,
SessionEntryType::Compaction(e) => e.parent_id,
SessionEntryType::Label(e) => e.parent_id,
SessionEntryType::SessionInfo(e) => e.parent_id,
SessionEntryType::Custom(e) => e.parent_id,
SessionEntryType::CustomMessage(e) => e.parent_id,
}
}
}
impl Default for SessionNavigator {
fn default() -> Self {
Self::new()
}
}
pub fn extract_user_message_text(content: &str) -> String {
content.to_string()
}
pub fn extract_custom_message_text(content: &str) -> String {
content.to_string()
}
pub fn is_user_message(entry: &SessionEntryType) -> bool {
matches!(
entry,
SessionEntryType::Message(msg) if msg.role.is_user()
)
}
pub fn is_custom_message(entry: &SessionEntryType) -> bool {
matches!(entry, SessionEntryType::CustomMessage(_))
}
pub fn is_assistant_message(entry: &SessionEntryType) -> bool {
matches!(
entry,
SessionEntryType::Message(msg) if msg.role.is_assistant()
)
}
#[cfg(test)]
mod tests {
use super::*;
struct NoOpSummarizer;
impl Summarizer for NoOpSummarizer {
fn summarize(
&self,
_entries: &[SessionEntryType],
_custom_instructions: Option<&str>,
_replace_instructions: bool,
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = Result<BranchSummaryResult, SummarizationError>>
+ Send
+ 'static,
>,
> {
Box::pin(async {
Ok(BranchSummaryResult {
summary: None,
read_files: vec![],
modified_files: vec![],
aborted: false,
error: None,
})
})
}
}
fn create_message(
id: Uuid,
parent_id: Option<Uuid>,
role: MessageRole,
content: &str,
) -> SessionEntryType {
SessionEntryType::Message(MessageEntry {
id,
parent_id,
timestamp: 0,
role,
content: content.to_string(),
})
}
fn entry_id(entry: &SessionEntryType) -> Uuid {
match entry {
SessionEntryType::Message(e) => e.id,
SessionEntryType::BranchSummary(e) => e.id,
SessionEntryType::Compaction(e) => e.id,
SessionEntryType::Label(e) => e.id,
SessionEntryType::SessionInfo(e) => e.id,
SessionEntryType::Custom(e) => e.id,
SessionEntryType::CustomMessage(e) => e.id,
}
}
#[test]
fn test_navigate_to_user_message() {
let mut nav = SessionNavigator::new();
let root_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let assistant_id = Uuid::new_v4();
nav.add_entry(create_message(root_id, None, MessageRole::User, "Hello"));
nav.add_entry(create_message(
user_id,
Some(root_id),
MessageRole::User,
"How are you?",
));
nav.add_entry(create_message(
assistant_id,
Some(user_id),
MessageRole::Assistant,
"I'm fine",
));
nav.branch(assistant_id);
let result = nav.navigate_tree(
user_id,
NavigationOptions::default(),
None as Option<&NoOpSummarizer>,
None,
);
assert!(!result.cancelled);
assert!(!result.aborted);
assert_eq!(result.editor_text, Some("How are you?".to_string()));
assert_eq!(nav.get_leaf_id(), Some(root_id));
}
#[test]
fn test_navigate_to_assistant_message() {
let mut nav = SessionNavigator::new();
let root_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let assistant_id = Uuid::new_v4();
nav.add_entry(create_message(root_id, None, MessageRole::User, "Hello"));
nav.add_entry(create_message(
user_id,
Some(root_id),
MessageRole::User,
"How are you?",
));
nav.add_entry(create_message(
assistant_id,
Some(user_id),
MessageRole::Assistant,
"I'm fine",
));
nav.branch(assistant_id);
let result = nav.navigate_tree(
assistant_id,
NavigationOptions::default(),
None as Option<&NoOpSummarizer>,
None,
);
assert!(!result.cancelled);
assert_eq!(nav.get_leaf_id(), Some(assistant_id));
}
#[test]
fn test_noop_navigation() {
let mut nav = SessionNavigator::new();
let entry_id = Uuid::new_v4();
nav.add_entry(create_message(entry_id, None, MessageRole::User, "Test"));
nav.branch(entry_id);
let result = nav.navigate_tree(
entry_id,
NavigationOptions::default(),
None as Option<&NoOpSummarizer>,
None,
);
assert!(!result.cancelled);
assert_eq!(result.editor_text, None);
}
#[test]
fn test_collect_entries_for_branch_summary() {
let root_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let assistant_id = Uuid::new_v4();
let branch_user_id = Uuid::new_v4();
let branch_assistant_id = Uuid::new_v4();
let entries = vec![
create_message(root_id, None, MessageRole::User, "Root"),
create_message(user_id, Some(root_id), MessageRole::User, "User"),
create_message(
assistant_id,
Some(user_id),
MessageRole::Assistant,
"Assistant",
),
create_message(
branch_user_id,
Some(user_id),
MessageRole::User,
"Branch User",
),
create_message(
branch_assistant_id,
Some(branch_user_id),
MessageRole::Assistant,
"Branch Assistant",
),
];
let nav = SessionNavigator::from_entries(entries, Some(branch_assistant_id));
let result = nav.collect_entries_for_branch_summary(Some(branch_assistant_id), root_id);
assert_eq!(result.common_ancestor_id, Some(root_id));
assert_eq!(result.entries.len(), 3);
}
#[test]
fn test_label_attachment() {
let mut nav = SessionNavigator::new();
let entry_id = Uuid::new_v4();
nav.add_entry(create_message(entry_id, None, MessageRole::User, "Test"));
let label_id = nav.append_label_change(entry_id, Some("Important".to_string()));
assert_eq!(nav.get_label(entry_id), Some("Important"));
assert!(nav.get_entry(label_id).is_some());
nav.append_label_change(entry_id, None);
assert_eq!(nav.get_label(entry_id), None);
}
#[test]
fn test_branch_with_summary() {
let mut nav = SessionNavigator::new();
let entry_id = Uuid::new_v4();
nav.add_entry(create_message(entry_id, None, MessageRole::User, "Test"));
nav.branch(entry_id);
let summary_id =
nav.branch_with_summary(Some(entry_id), "This is a summary".to_string(), None, false);
assert!(nav.get_entry(summary_id).is_some());
assert_eq!(nav.get_leaf_id(), Some(entry_id));
match nav.get_entry(summary_id) {
Some(SessionEntryType::BranchSummary(e)) => {
assert_eq!(e.summary, "This is a summary");
}
_ => panic!("Expected branch summary entry"),
}
}
#[test]
fn test_new_navigator_has_no_leaf() {
let nav = SessionNavigator::new();
assert!(nav.get_leaf_id().is_none());
}
#[test]
fn test_default_navigator_has_no_leaf() {
let nav = SessionNavigator::default();
assert!(nav.get_leaf_id().is_none());
}
#[test]
fn test_get_entry_returns_none_for_unknown() {
let nav = SessionNavigator::new();
assert!(nav.get_entry(Uuid::new_v4()).is_none());
}
#[test]
fn test_get_branch_returns_empty_when_no_entries() {
let nav = SessionNavigator::new();
assert!(nav.get_branch(None).is_empty());
}
#[test]
fn test_get_branch_returns_full_path() {
let mut nav = SessionNavigator::new();
let root_id = Uuid::new_v4();
let mid_id = Uuid::new_v4();
let leaf_id = Uuid::new_v4();
nav.add_entry(create_message(root_id, None, MessageRole::User, "Root"));
nav.add_entry(create_message(
mid_id,
Some(root_id),
MessageRole::Assistant,
"Mid",
));
nav.add_entry(create_message(
leaf_id,
Some(mid_id),
MessageRole::User,
"Leaf",
));
nav.branch(leaf_id);
let branch = nav.get_branch(None);
assert_eq!(branch.len(), 3);
assert_eq!(entry_id(branch[0]), root_id);
assert_eq!(entry_id(branch[1]), mid_id);
assert_eq!(entry_id(branch[2]), leaf_id);
}
#[test]
fn test_get_children() {
let mut nav = SessionNavigator::new();
let parent_id = Uuid::new_v4();
let child_a = Uuid::new_v4();
let child_b = Uuid::new_v4();
nav.add_entry(create_message(parent_id, None, MessageRole::User, "Parent"));
nav.add_entry(create_message(
child_a,
Some(parent_id),
MessageRole::Assistant,
"A",
));
nav.add_entry(create_message(
child_b,
Some(parent_id),
MessageRole::Assistant,
"B",
));
let children = nav.get_children(parent_id);
assert_eq!(children.len(), 2);
}
#[test]
fn test_get_children_of_leaf() {
let mut nav = SessionNavigator::new();
let id = Uuid::new_v4();
nav.add_entry(create_message(id, None, MessageRole::User, "Solo"));
let children = nav.get_children(id);
assert!(children.is_empty());
}
#[test]
fn test_branch_switches_leaf() {
let mut nav = SessionNavigator::new();
let id = Uuid::new_v4();
nav.add_entry(create_message(id, None, MessageRole::User, "Test"));
nav.branch(id);
assert_eq!(nav.get_leaf_id(), Some(id));
nav.reset_leaf();
assert_eq!(nav.get_leaf_id(), None);
}
#[test]
fn test_reset_leaf() {
let mut nav = SessionNavigator::new();
let id = Uuid::new_v4();
nav.add_entry(create_message(id, None, MessageRole::User, "Test"));
nav.branch(id);
assert_eq!(nav.get_leaf_id(), Some(id));
nav.reset_leaf();
assert!(nav.get_leaf_id().is_none());
}
#[test]
fn test_from_entries_preserves_leaf() {
let id1 = Uuid::new_v4();
let id2 = Uuid::new_v4();
let entries = vec![
create_message(id1, None, MessageRole::User, "A"),
create_message(id2, Some(id1), MessageRole::Assistant, "B"),
];
let nav = SessionNavigator::from_entries(entries, Some(id2));
assert_eq!(nav.get_leaf_id(), Some(id2));
assert!(nav.get_entry(id1).is_some());
assert!(nav.get_entry(id2).is_some());
}
#[test]
fn test_navigate_to_nonexistent_returns_cancelled() {
let mut nav = SessionNavigator::new();
let id = Uuid::new_v4();
nav.add_entry(create_message(id, None, MessageRole::User, "Test"));
nav.branch(id);
let result = nav.navigate_tree(
Uuid::new_v4(),
NavigationOptions::default(),
None as Option<&NoOpSummarizer>,
None,
);
assert!(result.cancelled);
}
#[test]
fn test_navigate_to_root_resets_leaf() {
let mut nav = SessionNavigator::new();
let root_id = Uuid::new_v4();
let child_id = Uuid::new_v4();
nav.add_entry(create_message(root_id, None, MessageRole::User, "Root"));
nav.add_entry(create_message(
child_id,
Some(root_id),
MessageRole::Assistant,
"Child",
));
nav.branch(child_id);
let result = nav.navigate_tree(
root_id,
NavigationOptions::default(),
None as Option<&NoOpSummarizer>,
None,
);
assert!(!result.cancelled);
assert_eq!(result.editor_text, Some("Root".to_string()));
assert_eq!(nav.get_leaf_id(), None);
}
#[test]
fn test_collect_entries_no_old_leaf() {
let target_id = Uuid::new_v4();
let mut nav = SessionNavigator::new();
nav.add_entry(create_message(target_id, None, MessageRole::User, "T"));
let result = nav.collect_entries_for_branch_summary(None, target_id);
assert!(result.entries.is_empty());
assert_eq!(result.common_ancestor_id, None);
}
#[test]
fn test_collect_entries_same_branch_common_ancestor() {
let root_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let assistant_id = Uuid::new_v4();
let entries = vec![
create_message(root_id, None, MessageRole::User, "Root"),
create_message(user_id, Some(root_id), MessageRole::User, "User"),
create_message(assistant_id, Some(user_id), MessageRole::Assistant, "Asst"),
];
let nav = SessionNavigator::from_entries(entries, Some(assistant_id));
let result = nav.collect_entries_for_branch_summary(Some(assistant_id), user_id);
assert_eq!(result.common_ancestor_id, Some(user_id));
assert_eq!(result.entries.len(), 1);
}
#[test]
fn test_label_timestamp() {
let mut nav = SessionNavigator::new();
let id = Uuid::new_v4();
nav.add_entry(create_message(id, None, MessageRole::User, "Test"));
assert!(nav.get_label_timestamp(id).is_none());
nav.append_label_change(id, Some("marker".to_string()));
assert!(nav.get_label_timestamp(id).is_some());
}
#[test]
fn test_label_replace() {
let mut nav = SessionNavigator::new();
let id = Uuid::new_v4();
nav.add_entry(create_message(id, None, MessageRole::User, "Test"));
nav.append_label_change(id, Some("first".to_string()));
assert_eq!(nav.get_label(id), Some("first"));
nav.append_label_change(id, Some("second".to_string()));
assert_eq!(nav.get_label(id), Some("second"));
}
#[test]
fn test_label_nonexistent_entry_returns_nil() {
let mut nav = SessionNavigator::new();
let id = nav.append_label_change(Uuid::new_v4(), Some("ghost".to_string()));
assert_eq!(id, Uuid::nil());
}
#[test]
fn test_branch_with_summary_details() {
let mut nav = SessionNavigator::new();
let id = Uuid::new_v4();
nav.add_entry(create_message(id, None, MessageRole::User, "Test"));
nav.branch(id);
let details = BranchSummaryDetails {
read_files: vec!["a.rs".into()],
modified_files: vec!["b.rs".into()],
};
let summary_id = nav.branch_with_summary(Some(id), "Summary".into(), Some(details), true);
match nav.get_entry(summary_id) {
Some(SessionEntryType::BranchSummary(e)) => {
assert_eq!(e.summary, "Summary");
assert!(e.from_hook.unwrap_or(false));
assert!(e.details.is_some());
let d = e.details.as_ref().unwrap();
assert_eq!(d.read_files, vec!["a.rs"]);
assert_eq!(d.modified_files, vec!["b.rs"]);
}
_ => panic!("Expected branch summary"),
}
}
#[test]
fn test_navigate_with_extension_cancel() {
let mut nav = SessionNavigator::new();
let root_id = Uuid::new_v4();
let child_id = Uuid::new_v4();
nav.add_entry(create_message(root_id, None, MessageRole::User, "R"));
nav.add_entry(create_message(
child_id,
Some(root_id),
MessageRole::Assistant,
"C",
));
nav.branch(child_id);
let hook = |_: TreePreparation| -> BeforeTreeHookResult {
BeforeTreeHookResult {
cancel: true,
summary: None,
custom_instructions: None,
replace_instructions: None,
label: None,
}
};
let result = nav.navigate_tree(
root_id,
NavigationOptions::default(),
None as Option<&NoOpSummarizer>,
Some(&hook),
);
assert!(result.cancelled);
}
#[test]
fn test_navigate_with_extension_summary() {
let mut nav = SessionNavigator::new();
let root_id = Uuid::new_v4();
let child_id = Uuid::new_v4();
nav.add_entry(create_message(root_id, None, MessageRole::User, "R"));
nav.add_entry(create_message(
child_id,
Some(root_id),
MessageRole::Assistant,
"C",
));
nav.branch(child_id);
let hook = |_: TreePreparation| -> BeforeTreeHookResult {
BeforeTreeHookResult {
cancel: false,
summary: Some(ExtensionSummary {
summary: "Ext summary".into(),
details: None,
}),
custom_instructions: None,
replace_instructions: None,
label: None,
}
};
let result = nav.navigate_tree(
root_id,
NavigationOptions {
summarize: true,
..Default::default()
},
None as Option<&NoOpSummarizer>,
Some(&hook),
);
assert!(!result.cancelled);
assert!(result.summary_entry_id.is_some());
let sid = result.summary_entry_id.unwrap();
match nav.get_entry(sid) {
Some(SessionEntryType::BranchSummary(e)) => {
assert_eq!(e.summary, "Ext summary");
}
_ => panic!("Expected branch summary from extension"),
}
}
#[test]
fn test_message_role_checks() {
assert!(MessageRole::User.is_user());
assert!(!MessageRole::User.is_assistant());
assert!(MessageRole::Assistant.is_assistant());
assert!(!MessageRole::Assistant.is_user());
assert!(!MessageRole::System.is_user());
assert!(!MessageRole::Tool.is_user());
}
#[test]
fn test_utility_functions() {
let user_entry = create_message(Uuid::new_v4(), None, MessageRole::User, "hi");
let asst_entry = create_message(Uuid::new_v4(), None, MessageRole::Assistant, "yo");
let sys_entry = create_message(Uuid::new_v4(), None, MessageRole::System, "sys");
assert!(is_user_message(&user_entry));
assert!(!is_user_message(&asst_entry));
assert!(is_assistant_message(&asst_entry));
assert!(!is_assistant_message(&user_entry));
assert!(!is_user_message(&sys_entry));
assert!(!is_assistant_message(&sys_entry));
}
#[test]
fn test_session_entry_type_accessors() {
let id = Uuid::new_v4();
let msg = SessionEntryType::Message(MessageEntry {
id,
parent_id: None,
timestamp: 42,
role: MessageRole::User,
content: "test".into(),
});
match &msg {
SessionEntryType::Message(e) => {
assert_eq!(e.id, id);
assert_eq!(e.parent_id, None);
assert_eq!(e.timestamp, 42);
assert_eq!(e.role, MessageRole::User);
assert_eq!(e.content, "test");
}
_ => panic!("Expected Message"),
}
}
#[test]
fn test_get_all_entries() {
let mut nav = SessionNavigator::new();
let id1 = Uuid::new_v4();
let id2 = Uuid::new_v4();
nav.add_entry(create_message(id1, None, MessageRole::User, "A"));
nav.add_entry(create_message(id2, Some(id1), MessageRole::Assistant, "B"));
let all = nav.get_entries();
assert_eq!(all.len(), 2);
}
}