返回 CodeWhale
session_tree.rs
根目录 / crates / tui / src / session_tree.rs
1 use chrono::{DateTime, Utc};
2 use codewhale_models::{ContentBlock, Message, Role};
3 use serde::{Deserialize, Serialize};
4 use std::collections::{HashMap, HashSet};
5 pub const CURRENT_JOURNAL_SCHEMA_VERSION: u32 = 1;
6 pub type EntryId = String;
7 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
8 pub struct SpawnDepth(pub u32);
9 impl SpawnDepth {
10 pub fn next(self) -> Self {
11 Self(self.0.saturating_add(1))
12 }
13 }
14 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15 #[serde(tag = "kind", rename_all = "snake_case")]
16 pub enum SessionEntryKind {
17 Message {
18 message: Message,
19 },
20 User {
21 text: String,
22 },
23 Assistant {
24 text: String,
25 },
26 Compaction {
27 summary: String,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 tokens_before: Option<u64>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 tokens_after: Option<u64>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 model: Option<String>,
34 },
35 BranchSummary {
36 branch_id: String,
37 summary: String,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 parent_branch_id: Option<String>,
40 },
41 System {
42 content: String,
43 },
44 }
45 impl SessionEntryKind {
46 pub fn is_contextual(&self) -> bool {
47 matches!(
48 self,
49 Self::Message { .. } | Self::User { .. } | Self::Assistant { .. }
50 )
51 }
52 pub fn as_message(&self) -> Option<Message> {
53 match self {
54 Self::Message { message } => Some(message.clone()),
55 Self::User { text } => Some(Message {
56 role: Role::User,
57 content: vec![ContentBlock::Text {
58 text: text.clone(),
59 cache_control: None,
60 }],
61 }),
62 Self::Assistant { text } => Some(Message {
63 role: Role::Assistant,
64 content: vec![ContentBlock::Text {
65 text: text.clone(),
66 cache_control: None,
67 }],
68 }),
69 Self::Compaction { summary, .. } => Some(Message {
70 role: Role::System,
71 content: vec![ContentBlock::Text {
72 text: format!("[compaction summary] {summary}"),
73 cache_control: None,
74 }],
75 }),
76 Self::BranchSummary { summary, .. } => Some(Message {
77 role: Role::System,
78 content: vec![ContentBlock::Text {
79 text: format!("[branch summary] {summary}"),
80 cache_control: None,
81 }],
82 }),
83 Self::System { content } => Some(Message {
84 role: Role::System,
85 content: vec![ContentBlock::Text {
86 text: content.clone(),
87 cache_control: None,
88 }],
89 }),
90 }
91 }
92 }
93 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
94 pub struct SessionEntry {
95 pub id: EntryId,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub parent_id: Option<EntryId>,
98 #[serde(flatten)]
99 pub kind: SessionEntryKind,
100 pub created_at: DateTime<Utc>,
101 #[serde(default)]
102 pub spawn_depth: u32,
103 }
104 impl SessionEntry {
105 pub fn new(kind: SessionEntryKind, parent_id: Option<EntryId>, spawn_depth: u32) -> Self {
106 Self {
107 id: uuid::Uuid::new_v4().to_string(),
108 parent_id,
109 kind,
110 created_at: Utc::now(),
111 spawn_depth,
112 }
113 }
114 pub fn short_id(&self) -> &str {
115 if self.id.len() >= 8 {
116 &self.id[..8]
117 } else {
118 &self.id
119 }
120 }
121 }
122 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
123 pub struct SessionJournal {
124 #[serde(default)]
125 pub entries: Vec<SessionEntry>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub leaf_id: Option<EntryId>,
128 #[serde(default = "default_journal_schema_version")]
129 pub schema_version: u32,
130 #[serde(default)]
131 pub spawn_depth: u32,
132 }
133 fn default_journal_schema_version() -> u32 {
134 CURRENT_JOURNAL_SCHEMA_VERSION
135 }
136 impl SessionJournal {
137 pub fn new() -> Self {
138 Self {
139 entries: Vec::new(),
140 leaf_id: None,
141 schema_version: CURRENT_JOURNAL_SCHEMA_VERSION,
142 spawn_depth: 0,
143 }
144 }
145 pub fn with_spawn_depth(depth: u32) -> Self {
146 Self {
147 spawn_depth: depth,
148 ..Self::new()
149 }
150 }
151 pub fn append(&mut self, kind: SessionEntryKind) -> EntryId {
152 self.append_stamped(kind, Utc::now())
153 }
154 /// Append an entry carrying the time the underlying event happened, not
155 /// the time the save ran. `created_at` is the journal's timeline; stamping
156 /// it at append is what keeps a rebuilt journal honest — a save must never
157 /// rewrite an entry's time to the moment it was written.
158 pub fn append_stamped(&mut self, kind: SessionEntryKind, created_at: DateTime<Utc>) -> EntryId {
159 let mut entry = SessionEntry::new(kind, self.leaf_id.clone(), self.spawn_depth);
160 entry.created_at = created_at;
161 let id = entry.id.clone();
162 self.entries.push(entry);
163 self.leaf_id = Some(id.clone());
164 id
165 }
166 pub fn append_message(&mut self, message: Message) -> EntryId {
167 self.append(SessionEntryKind::Message { message })
168 }
169 pub fn append_compaction(
170 &mut self,
171 summary: String,
172 tokens_before: Option<u64>,
173 tokens_after: Option<u64>,
174 model: Option<String>,
175 ) -> EntryId {
176 self.append(SessionEntryKind::Compaction {
177 summary,
178 tokens_before,
179 tokens_after,
180 model,
181 })
182 }
183 pub fn append_branch_summary(
184 &mut self,
185 branch_id: String,
186 summary: String,
187 parent_branch_id: Option<String>,
188 ) -> EntryId {
189 self.append(SessionEntryKind::BranchSummary {
190 branch_id,
191 summary,
192 parent_branch_id,
193 })
194 }
195 pub fn branch_to(&mut self, entry_id: &str) -> Result<(), String> {
196 if self.entries.iter().any(|e| e.id == entry_id) {
197 self.leaf_id = Some(entry_id.to_string());
198 Ok(())
199 } else {
200 Err(format!("entry {entry_id} not found"))
201 }
202 }
203 pub fn fork_from(&self, from_entry_id: Option<&str>) -> Result<Self, String> {
204 let leaf = if let Some(id) = from_entry_id {
205 if !self.entries.iter().any(|e| e.id == id) {
206 return Err(format!("fork source {id} not found"));
207 }
208 Some(id.to_string())
209 } else {
210 self.leaf_id.clone()
211 };
212 Ok(Self {
213 entries: self.entries.clone(),
214 leaf_id: leaf,
215 schema_version: self.schema_version,
216 spawn_depth: self.spawn_depth.saturating_add(1),
217 })
218 }
219 pub fn index(&self) -> HashMap<&str, &SessionEntry> {
220 self.entries.iter().map(|e| (e.id.as_str(), e)).collect()
221 }
222 pub fn children_of(&self, parent_id: Option<&str>) -> Vec<&SessionEntry> {
223 self.entries
224 .iter()
225 .filter(|e| e.parent_id.as_deref() == parent_id)
226 .collect()
227 }
228 pub fn contains(&self, entry_id: &str) -> bool {
229 self.entries.iter().any(|e| e.id == entry_id)
230 }
231 pub fn leaf(&self) -> Option<&SessionEntry> {
232 self.leaf_id
233 .as_deref()
234 .and_then(|id| self.entries.iter().find(|e| e.id == id))
235 }
236 pub fn root_to_leaf(&self) -> Vec<&SessionEntry> {
237 let index: HashMap<&str, &SessionEntry> =
238 self.entries.iter().map(|e| (e.id.as_str(), e)).collect();
239 let mut path = Vec::new();
240 let mut cur = self.leaf_id.as_deref();
241 let mut seen = HashSet::new();
242 while let Some(id) = cur {
243 if !seen.insert(id) {
244 break;
245 }
246 if let Some(entry) = index.get(id) {
247 path.push(*entry);
248 cur = entry.parent_id.as_deref();
249 } else {
250 break;
251 }
252 }
253 path.reverse();
254 path
255 }
256 pub fn active_messages(&self, include_system: bool) -> Vec<Message> {
257 self.root_to_leaf()
258 .into_iter()
259 .filter_map(|e| {
260 if !include_system && !e.kind.is_contextual() {
261 return None;
262 }
263 e.kind.as_message()
264 })
265 .collect()
266 }
267 pub fn leaves(&self) -> Vec<&SessionEntry> {
268 let parents: HashSet<&str> = self
269 .entries
270 .iter()
271 .filter_map(|e| e.parent_id.as_deref())
272 .collect();
273 self.entries
274 .iter()
275 .filter(|e| !parents.contains(e.id.as_str()))
276 .collect()
277 }
278 pub fn is_empty(&self) -> bool {
279 self.entries.is_empty()
280 }
281 pub fn len(&self) -> usize {
282 self.entries.len()
283 }
284 pub fn validate(&self) -> Result<(), String> {
285 let ids: HashSet<&str> = self.entries.iter().map(|e| e.id.as_str()).collect();
286 for entry in &self.entries {
287 if let Some(parent) = entry.parent_id.as_deref()
288 && !ids.contains(parent)
289 {
290 return Err(format!("entry {} missing parent {}", entry.id, parent));
291 }
292 }
293 if let Some(leaf) = self.leaf_id.as_deref()
294 && !ids.contains(leaf)
295 {
296 return Err(format!("leaf {leaf} not found"));
297 }
298 Ok(())
299 }
300 pub fn from_messages(messages: Vec<Message>, spawn_depth: u32) -> Self {
301 let mut j = Self::with_spawn_depth(spawn_depth);
302 for msg in messages {
303 j.append(SessionEntryKind::Message { message: msg });
304 }
305 j
306 }
307 /// Build the journal honoring a per-message append stamp. `stamps[i]` is
308 /// the time `messages[i]` entered the conversation; a missing stamp falls
309 /// back to now, so a drifted caller degrades to save-time ordering rather
310 /// than dropping the message.
311 pub fn from_messages_stamped(
312 messages: Vec<Message>,
313 stamps: &[DateTime<Utc>],
314 spawn_depth: u32,
315 ) -> Self {
316 let mut j = Self::with_spawn_depth(spawn_depth);
317 for (index, msg) in messages.into_iter().enumerate() {
318 let created_at = stamps.get(index).copied().unwrap_or_else(Utc::now);
319 j.append_stamped(SessionEntryKind::Message { message: msg }, created_at);
320 }
321 j
322 }
323 pub fn to_messages(&self) -> Vec<Message> {
324 self.active_messages(true)
325 }
326
327 /// Make `messages` the active projection without rewriting the journal.
328 ///
329 /// The existing active branch remains as evidence. We reuse its longest
330 /// unchanged prefix, then append the repaired suffix as a sibling branch.
331 pub fn rebranch_active_messages(&mut self, messages: &[Message]) {
332 self.rebranch_active_messages_stamped(messages, &[]);
333 }
334
335 /// Preserve existing entry identity and timestamps; only append the changed
336 /// suffix, keeping the previous branch reachable.
337 pub fn rebranch_active_messages_stamped(
338 &mut self,
339 messages: &[Message],
340 stamps: &[DateTime<Utc>],
341 ) {
342 let active_path = self.root_to_leaf();
343 let shared_prefix = active_path
344 .iter()
345 .zip(messages)
346 .take_while(|(entry, message)| entry.kind.as_message().as_ref() == Some(*message))
347 .count();
348 self.leaf_id = shared_prefix
349 .checked_sub(1)
350 .map(|index| active_path[index].id.clone());
351 for (index, message) in messages.iter().enumerate().skip(shared_prefix) {
352 self.append_stamped(
353 SessionEntryKind::Message {
354 message: message.clone(),
355 },
356 stamps.get(index).copied().unwrap_or_else(Utc::now),
357 );
358 }
359 }
360 }
361 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
362 pub struct SessionImportContainer {
363 pub format_version: u32,
364 pub source: String,
365 pub metadata: Option<serde_json::Value>,
366 pub entries: Vec<SessionEntry>,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub leaf_id: Option<EntryId>,
369 pub exported_at: DateTime<Utc>,
370 #[serde(default)]
371 pub spawn_depth: u32,
372 }
373 impl SessionImportContainer {
374 pub fn new(
375 source: String,
376 journal: &SessionJournal,
377 metadata: Option<serde_json::Value>,
378 ) -> Self {
379 Self {
380 format_version: CURRENT_JOURNAL_SCHEMA_VERSION,
381 source,
382 metadata,
383 entries: journal.entries.clone(),
384 leaf_id: journal.leaf_id.clone(),
385 exported_at: Utc::now(),
386 spawn_depth: journal.spawn_depth,
387 }
388 }
389 pub fn into_journal(self) -> Result<SessionJournal, String> {
390 let j = SessionJournal {
391 entries: self.entries,
392 leaf_id: self.leaf_id,
393 schema_version: self.format_version,
394 spawn_depth: self.spawn_depth,
395 };
396 j.validate()?;
397 Ok(j)
398 }
399 pub fn to_json(&self) -> Result<String, serde_json::Error> {
400 serde_json::to_string_pretty(self)
401 }
402 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
403 serde_json::from_str(json)
404 }
405 }
406 pub fn render_tree(journal: &SessionJournal) -> String {
407 if journal.entries.is_empty() {
408 return "(empty session — no entries yet)".to_string();
409 }
410 let index = journal.index();
411 let mut out = String::new();
412 let mut children: HashMap<Option<&str>, Vec<&SessionEntry>> = HashMap::new();
413 for entry in &journal.entries {
414 children
415 .entry(entry.parent_id.as_deref())
416 .or_default()
417 .push(entry);
418 }
419 let active_ids: HashSet<&str> = journal
420 .root_to_leaf()
421 .iter()
422 .map(|e| e.id.as_str())
423 .collect();
424 let leaf = journal.leaf_id.as_deref();
425 fn render_node(
426 out: &mut String,
427 children: &HashMap<Option<&str>, Vec<&SessionEntry>>,
428 active_ids: &HashSet<&str>,
429 leaf: Option<&str>,
430 parent: Option<&str>,
431 depth: usize,
432 ) {
433 let Some(nodes) = children.get(&parent) else {
434 return;
435 };
436 for (idx, entry) in nodes.iter().enumerate() {
437 let is_last = idx + 1 == nodes.len();
438 let prefix = if depth == 0 {
439 "".to_string()
440 } else {
441 let mut p = String::new();
442 for _ in 0..depth - 1 {
443 p.push_str("│ ");
444 }
445 if is_last {
446 p.push_str("└─ ");
447 } else {
448 p.push_str("├─ ");
449 }
450 p
451 };
452 let marker = if Some(entry.id.as_str()) == leaf {
453 "*"
454 } else if active_ids.contains(entry.id.as_str()) {
455 "●"
456 } else {
457 "○"
458 };
459 let kind_label = match &entry.kind {
460 SessionEntryKind::Message { message } => {
461 let role = &message.role;
462 let snippet: String = message
463 .content
464 .iter()
465 .filter_map(|b| match b {
466 ContentBlock::Text { text, .. } => Some(text.as_str()),
467 _ => None,
468 })
469 .collect::<Vec<_>>()
470 .join(" ");
471 let short: String = snippet.chars().take(60).collect();
472 format!("{role}: {short}")
473 }
474 SessionEntryKind::User { text } => {
475 let short: String = text.chars().take(60).collect();
476 format!("user: {short}")
477 }
478 SessionEntryKind::Assistant { text } => {
479 let short: String = text.chars().take(60).collect();
480 format!("assistant: {short}")
481 }
482 SessionEntryKind::Compaction { summary, .. } => {
483 let short: String = summary.chars().take(60).collect();
484 format!("compaction: {short}")
485 }
486 SessionEntryKind::BranchSummary {
487 branch_id, summary, ..
488 } => {
489 let short: String = summary.chars().take(60).collect();
490 format!("branch:{} {short}", &branch_id[..branch_id.len().min(8)])
491 }
492 SessionEntryKind::System { content } => {
493 let short: String = content.chars().take(60).collect();
494 format!("system: {short}")
495 }
496 };
497 out.push_str(&format!(
498 "{prefix}{marker} {} [{}] {kind_label}\n",
499 entry.short_id(),
500 entry.id
501 ));
502 render_node(out, children, active_ids, leaf, Some(&entry.id), depth + 1);
503 }
504 }
505 render_node(&mut out, &children, &active_ids, leaf, None, 0);
506 let _ = index;
507 if let Some(leaf_id) = leaf {
508 out.push_str(&format!(
509 "\nleaf: {leaf_id} (active, {} entries)\n",
510 journal.entries.len()
511 ));
512 }
513 out
514 }
515 #[cfg(test)]
516 mod tests {
517 use super::*;
518 use codewhale_models::{ContentBlock, Message, Role};
519 fn msg(role: &str, text: &str) -> Message {
520 Message {
521 role: Role::from(role),
522 content: vec![ContentBlock::Text {
523 text: text.to_string(),
524 cache_control: None,
525 }],
526 }
527 }
528 #[test]
529 fn append_creates_child_of_leaf() {
530 let mut j = SessionJournal::new();
531 let a = j.append(SessionEntryKind::User {
532 text: "hello".into(),
533 });
534 let b = j.append(SessionEntryKind::Assistant {
535 text: "world".into(),
536 });
537 assert_eq!(j.leaf_id.as_deref(), Some(b.as_str()));
538 let be = j.entries.iter().find(|e| e.id == b).unwrap();
539 assert_eq!(be.parent_id.as_deref(), Some(a.as_str()));
540 }
541 #[test]
542 fn branch_moves_leaf_only() {
543 let mut j = SessionJournal::new();
544 let a = j.append(SessionEntryKind::User { text: "a".into() });
545 let b = j.append(SessionEntryKind::User { text: "b".into() });
546 let _c = j.append(SessionEntryKind::User { text: "c".into() });
547 j.branch_to(&a).unwrap();
548 let d = j.append(SessionEntryKind::User { text: "d".into() });
549 assert_eq!(j.entries.len(), 4);
550 assert_eq!(j.leaf_id.as_deref(), Some(d.as_str()));
551 let path: Vec<String> = j.root_to_leaf().iter().map(|e| e.id.clone()).collect();
552 assert_eq!(path, vec![a.clone(), d.clone()]);
553 assert!(j.entries.iter().any(|e| e.id == b));
554 }
555 #[test]
556 fn from_messages_migrates() {
557 let msgs = vec![msg("user", "hi"), msg("assistant", "hello")];
558 let j = SessionJournal::from_messages(msgs, 0);
559 assert_eq!(j.entries.len(), 2);
560 assert!(j.validate().is_ok());
561 assert_eq!(j.root_to_leaf().len(), 2);
562 }
563 #[test]
564 fn repaired_messages_form_an_append_only_sibling_branch() {
565 let original = vec![
566 msg("user", "shared"),
567 msg("assistant", "broken"),
568 msg("user", "old tail"),
569 ];
570 let mut journal = SessionJournal::from_messages(original, 0);
571 let old_leaf = journal.leaf_id.clone().expect("old leaf");
572 let repaired = vec![
573 msg("user", "shared"),
574 msg("assistant", "repaired"),
575 msg("user", "new tail"),
576 ];
577
578 journal.rebranch_active_messages(&repaired);
579
580 assert_eq!(journal.to_messages(), repaired);
581 assert!(journal.contains(&old_leaf), "old evidence must remain");
582 assert_eq!(
583 journal.entries.len(),
584 5,
585 "one shared entry plus two branches"
586 );
587 }
588 #[test]
589 fn stamped_rebranch_keeps_prefix_identity_and_suffix_stamps() {
590 let stamp = |secs: i64| DateTime::from_timestamp(secs, 0).expect("stamp");
591 let mut j = SessionJournal::new();
592 j.append_stamped(
593 SessionEntryKind::Message {
594 message: msg("user", "a"),
595 },
596 stamp(100),
597 );
598 j.append_stamped(
599 SessionEntryKind::Message {
600 message: msg("assistant", "b"),
601 },
602 stamp(200),
603 );
604 let a_id = j.entries[0].id.clone();
605 j.rebranch_active_messages_stamped(
606 &[msg("user", "a"), msg("assistant", "b2")],
607 &[stamp(100), stamp(300)],
608 );
609 assert_eq!(j.entries.len(), 3);
610 assert_eq!(j.entries[0].id, a_id, "shared prefix keeps its id");
611 assert_eq!(j.entries[0].created_at, stamp(100));
612 let path = j.root_to_leaf();
613 assert_eq!(path.len(), 2);
614 assert_eq!(
615 path[1].created_at,
616 stamp(300),
617 "suffix keeps the live stamp"
618 );
619 assert!(
620 j.entries
621 .iter()
622 .any(|e| e.kind.as_message().as_ref() == Some(&msg("assistant", "b"))),
623 "replaced suffix survives as a sibling"
624 );
625 }
626 #[test]
627 fn compaction_fits() {
628 let mut j = SessionJournal::new();
629 let id = j.append_compaction("summary".into(), Some(1000), Some(100), None);
630 assert!(j.contains(&id));
631 assert!(matches!(
632 j.leaf().unwrap().kind,
633 SessionEntryKind::Compaction { .. }
634 ));
635 }
636 #[test]
637 fn branch_summary_fits() {
638 let mut j = SessionJournal::new();
639 let a = j.append(SessionEntryKind::User {
640 text: "root".into(),
641 });
642 let b = j.append_branch_summary(a.clone(), "branch summary".into(), None);
643 assert!(j.contains(&b));
644 }
645 #[test]
646 fn spawn_depth_fork() {
647 let mut j = SessionJournal::with_spawn_depth(1);
648 let forked = j.fork_from(None).unwrap();
649 assert_eq!(forked.spawn_depth, 2);
650 let a = j.append(SessionEntryKind::User {
651 text: "root".into(),
652 });
653 let fork2 = j.fork_from(Some(&a)).unwrap();
654 assert_eq!(fork2.spawn_depth, 2);
655 }
656 #[test]
657 fn foreign_roundtrip() {
658 let mut j = SessionJournal::new();
659 j.append(SessionEntryKind::User {
660 text: "hello".into(),
661 });
662 let c = SessionImportContainer::new("codewhale".into(), &j, None);
663 let json = c.to_json().unwrap();
664 let back = SessionImportContainer::from_json(&json).unwrap();
665 let j2 = back.into_journal().unwrap();
666 assert_eq!(j.entries.len(), j2.entries.len());
667 }
668 #[test]
669 fn render_marks_active() {
670 let mut j = SessionJournal::new();
671 j.append(SessionEntryKind::User {
672 text: "root".into(),
673 });
674 let tree = render_tree(&j);
675 assert!(tree.contains('*'));
676 }
677 #[test]
678 fn active_messages_root_to_leaf() {
679 let mut j = SessionJournal::new();
680 j.append(SessionEntryKind::User { text: "a".into() });
681 j.append(SessionEntryKind::User { text: "b".into() });
682 let msgs = j.active_messages(false);
683 assert_eq!(msgs.len(), 2);
684 j.branch_to(&j.entries[0].id.clone()).unwrap();
685 j.append(SessionEntryKind::User { text: "c".into() });
686 let msgs2 = j.active_messages(false);
687 assert_eq!(msgs2.len(), 2);
688 }
689 }
690
690 lines RUST