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)]
pub struct NavigationOptions {
pub summarize: bool,
pub custom_instructions: Option<String>,
pub replace_instructions: bool,
pub label: Option<String>,
}
impl Default for NavigationOptions {
fn default() -> Self {
Self {
summarize: false,
custom_instructions: None,
replace_instructions: false,
label: None,
}
}
}
#[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),
}
#[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 {
if old_leaf_id.is_none() {
return CollectEntriesResult {
entries: Vec::new(),
common_ancestor_id: None,
};
}
let old_leaf_id = old_leaf_id.unwrap();
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 rt = summarizer.summarize(
&collection.entries,
custom_instructions.as_deref(),
replace_instructions,
);
let runtime = tokio::runtime::Handle::current();
let result = runtime.block_on(rt);
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 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 {
self.branch(new_leaf_id.unwrap());
None
};
let has_label = label.is_some();
let has_summary = summary_text.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) {
tracing::warn!("Entry {} not found for branching", 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 {
if branch_from_id.is_some()
&& !self.entries_by_id.contains_key(&branch_from_id.unwrap())
{
tracing::warn!(
"Entry {:?} not found for branch_with_summary",
branch_from_id
);
}
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_else(|| 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) {
tracing::warn!("Entry {} not found for label change", 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::*;
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(),
})
}
#[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::<&dyn Summarizer>, 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(user_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::<&dyn Summarizer>, 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::<&dyn Summarizer>, None);
assert!(!result.cancelled);
assert_eq!(result.editor_text, None);
}
#[test]
fn test_collect_entries_for_branch_summary() {
let nav = SessionNavigator::new();
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 mut 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"),
];
entries.push(create_message(branch_user_id, Some(user_id), MessageRole::User, "Branch User"));
entries.push(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(), 2);
}
#[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"),
}
}
}