From d477562ac27c3cb0dc401c100204e698bae1beaa Mon Sep 17 00:00:00 2001 From: Yogthos Date: Wed, 20 May 2026 23:03:57 -0400 Subject: [PATCH] feat(phase 4): branch summary preservation in /tree (pi-style metadata) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of the 6-phase plan. Reference pattern: pi's `packages/coding-agent/src/core/branch-summarization.ts` — sibling branches dropped during compaction get preserved as structured summary records so the user can see what was lost. dirge implements the metadata-only variant (no LLM-generated summary call yet) — the schema is forward-compatible if Phase 4b adds LLM summaries later. ## Problem Phase 2 added sibling-branch pruning with a chat notification: "discarded N forked branches". Useful in the moment but the information was lost forever. Users with branched sessions couldn't see WHICH branches were dropped or any preview of what was in them. ## Fix New `BranchSummary` struct in `src/session/mod.rs`: ```rust pub struct BranchSummary { pub root_id: CompactString, // top of the pruned subtree pub parent_id: CompactString, // still-present parent (may be dropped too) pub message_count: usize, pub preview: String, // "[label] first 80 chars..." pub created_at: String, // RFC3339 } ``` `Session::branch_summaries: Vec` with `#[serde(default)]` for back-compat. Cleared by `reset_to_new` (else fresh sessions would inherit phantom records). ### Capture during compress + rewind Before pruning, walk the `to_prune` set and identify SUBTREE ROOTS — nodes in `to_prune` whose direct parent was in `dropped_set` (the closest dropped-active-path ancestor). For each root, build a `BranchSummary`: - `root_id` and `parent_id` for correlation - Walk descendants to count the subtree size - Preview = optional label (`[explore-alt]`) + first 80 chars of the root's message content Identical algorithm in `Session::compress_reporting` and `ui::mod.rs::rewind_session`. ### Surface in `/tree` `render_tree` appends a "Summarized branches" section after the linear tree render: ``` * abc12345 user "the active path…" abc12346 asst "the next message…" Summarized branches (1): pruned during compress/rewind └─ parent abc12345 · 2 msgs · [explore-alt] let me try a different… ``` Users browsing `/tree` after a compress can now see exactly which branches were preserved and which were lost. ## Tests 2 new session tests, written failing first: - `compress_records_branch_summary_for_pruned_siblings`: builds a session with u1 → [a1, u2, a2] linear + a sibling subtree (sib_alpha labeled "explore-alt" → sib_beta) rooted at u1, compresses past u1, asserts the resulting `branch_summaries` has 1 entry with the right parent_id (u1), message_count (2), and preview containing "explore-alt" or the alpha sibling's content. - `reset_to_new_clears_branch_summaries`: `/clear`-equivalent wipes the summary list along with everything else. Total: 646 pass (was 644), 0 fail, all build profiles clean. ## Not in Phase 4 (deferred to optional Phase 4b) - LLM-generated summary content. Today the preview is the truncated content + label; richer one-sentence summaries would require routing the LLM client into `Session::compress` (currently sync) — bigger plumbing change. The `BranchSummary` schema can accommodate a future `summary: Option` field without breaking back-compat. - Restoration as agent context when the user navigates "back" to a summarized branch. Today summaries are display-only (`/tree`). Navigation-time injection would need a new tree op (e.g. `harness/peek-summary` or a system message injection) — out of scope for the metadata-only MVP. ## Test plan - [x] `cargo test --features plugin` -> 646 pass. - [x] `cargo build --all-features` -> compiles. - [x] `cargo build --no-default-features` -> compiles. ## Up next: Phase 5 (per user request, skip Phase 6) `/allow list | remove | add ` CRUD slash command. User asked to skip Phase 6 (cost tracking). --- src/session/mod.rs | 208 +++++++++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 65 ++++++++++++++ src/ui/tree.rs | 23 +++++ 3 files changed, 296 insertions(+) diff --git a/src/session/mod.rs b/src/session/mod.rs index 7fdb23a9..8a66bc84 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -143,6 +143,34 @@ pub struct SessionTree { pub leaf_id: Option, } +/// Lightweight record of a forked subtree that was pruned during +/// compress / rewind. Phase 4: pi-style preservation +/// (`packages/coding-agent/src/core/branch-summarization.ts`) at +/// metadata-only granularity — no LLM summary call. Captures +/// enough info (count + preview + parent id) that the user can +/// find pruned branches in `/tree` and understand what was lost. +/// A future Phase 4b could generate full LLM summaries; the schema +/// is forward-compatible. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct BranchSummary { + /// Id of the branch's root node (the topmost node of the + /// pruned subtree). Kept for debugging — the node itself no + /// longer exists in `tree.entries`. + pub root_id: CompactString, + /// Id of the still-present parent that the branch hung off. + /// May itself have been pruned in the same compress (e.g. + /// when both parent and sibling subtree get dropped because + /// the parent was the dropped active-path message). + pub parent_id: CompactString, + /// How many nodes were in the pruned subtree. + pub message_count: usize, + /// Human-readable preview: branch label (if any) + first + /// chars of the root message's content. Shown in `/tree`. + pub preview: String, + /// RFC3339 timestamp of when the prune happened. + pub created_at: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PermissionAllowEntry { pub tool: String, @@ -224,6 +252,12 @@ pub struct Session { /// populates it from `messages` for legacy session files. #[serde(default)] pub message_store: HashMap, + /// Phase 4 — metadata records for forked subtrees that were + /// pruned during compress / rewind. Surfaces in `/tree` so the + /// user can see what branches were dropped. Defaulted on + /// deserialize for back-compat with pre-Phase-4 session files. + #[serde(default)] + pub branch_summaries: Vec, /// Active prompt name (e.g. "code", "plan", "review"). Persisted /// with the session so resuming via `-c` / `/sessions ` /// restores the same prompt the user had active. Defaulted to @@ -260,6 +294,7 @@ impl Session { next_entry_seq: 0, tree: SessionTree::default(), message_store: HashMap::new(), + branch_summaries: Vec::new(), current_prompt_name: None, } } @@ -592,6 +627,10 @@ impl Session { // testing session) silently transfer to an unrelated fresh // session. Each new session re-asks for permissions. self.permission_allowlist.clear(); + // Phase 4: branch summaries are per-session — wipe with + // everything else so a fresh session doesn't inherit + // phantom branch records from the prior one. + self.branch_summaries.clear(); // Note: model/provider/context_window/working_dir preserved // so the host can keep the same agent runtime. } @@ -741,6 +780,77 @@ impl Session { // without a notification, hence the count. let sibling_pruned_count = to_prune.len().saturating_sub(dropped_set.len()); + // Phase 4: capture a BranchSummary per pruned subtree + // BEFORE removing nodes (we need to read the root sibling's + // content + label). A "subtree root" is a node that's in + // `to_prune` AND whose parent is in `dropped_set` (i.e. the + // closest dropped-path ancestor). Each unique subtree root + // yields one summary. + let now_rfc = chrono::Utc::now().to_rfc3339(); + let mut subtree_summaries: Vec = Vec::new(); + for id in &to_prune { + if dropped_set.contains(id) { + // Active-path node; not a sibling subtree root. + continue; + } + let node = match self.tree.entries.get(id) { + Some(n) => n, + None => continue, + }; + let parent = match &node.parent { + Some(p) => p, + None => continue, + }; + if !dropped_set.contains(parent) { + // Not at the top of its subtree (some ancestor is + // also a sibling being pruned). Skip — only the + // closest-to-active-path ancestor gets a summary. + continue; + } + // Walk the subtree rooted here to count nodes. + let mut count = 0usize; + let mut stack = vec![id.clone()]; + while let Some(cur) = stack.pop() { + if !to_prune.contains(&cur) { + continue; + } + count += 1; + for (child_id, child_node) in self.tree.entries.iter() { + if child_node.parent.as_ref() == Some(&cur) { + stack.push(child_id.clone()); + } + } + } + // Preview: branch label (if any) + first chars of the + // root's message content. Keeps the user oriented when + // browsing `/tree`. + let label_prefix = node + .label + .as_deref() + .map(|l| format!("[{}] ", l)) + .unwrap_or_default(); + let body_preview = self + .message_store + .get(id) + .map(|m| { + let s: String = m.content.chars().take(80).collect(); + if m.content.chars().count() > 80 { + format!("{}…", s) + } else { + s + } + }) + .unwrap_or_default(); + subtree_summaries.push(BranchSummary { + root_id: id.clone(), + parent_id: parent.clone(), + message_count: count, + preview: format!("{}{}", label_prefix, body_preview), + created_at: now_rfc.clone(), + }); + } + self.branch_summaries.extend(subtree_summaries); + for id in &to_prune { self.tree.entries.remove(id); self.message_store.remove(id); @@ -1312,6 +1422,104 @@ mod tests { assert_eq!(s.messages.len(), 1); } + /// Phase 4 — when compress prunes a sibling subtree, it records + /// a `BranchSummary` capturing what was lost: parent id, + /// message count, preview text. Without this users see + /// "discarded N branches" with no way to know what was in them. + #[test] + fn compress_records_branch_summary_for_pruned_siblings() { + let mut s = Session::new("p", "m", 0); + s.add_message(MessageRole::User, "u1"); + let u1_id = s.messages.last().unwrap().id.clone(); + s.add_message(MessageRole::Assistant, "a1"); + s.add_message(MessageRole::User, "u2 keep"); + s.add_message(MessageRole::Assistant, "a2 keep"); + // Graft a sibling pair under u1. + let sib1_id = CompactString::new("sib_alpha"); + let sib2_id = CompactString::new("sib_beta"); + s.tree.entries.insert( + sib1_id.clone(), + TreeNode { + id: sib1_id.clone(), + parent: Some(u1_id.clone()), + timestamp: 100, + label: Some("explore-alt".to_string()), + }, + ); + s.tree.entries.insert( + sib2_id.clone(), + TreeNode { + id: sib2_id.clone(), + parent: Some(sib1_id.clone()), + timestamp: 200, + label: None, + }, + ); + s.message_store.insert( + sib1_id.clone(), + SessionMessage { + role: MessageRole::Assistant, + content: CompactString::from( + "let me try a different approach: investigate the foo module first", + ), + estimated_tokens: 10, + id: sib1_id.clone(), + timestamp: 100, + tool_calls: Vec::new(), + }, + ); + s.message_store.insert( + sib2_id.clone(), + SessionMessage { + role: MessageRole::User, + content: CompactString::from("continue with that"), + estimated_tokens: 3, + id: sib2_id.clone(), + timestamp: 200, + tool_calls: Vec::new(), + }, + ); + + let baseline = s.branch_summaries.len(); + let pruned = s.compress_reporting("summary".to_string(), 2, 10); + assert_eq!(pruned, 2, "expected 2 sibling nodes pruned"); + assert_eq!( + s.branch_summaries.len(), + baseline + 1, + "expected 1 branch summary; got {:?}", + s.branch_summaries, + ); + let summary = s.branch_summaries.last().unwrap(); + // Parent is u1 (the closest still-active-or-dropped ancestor + // of the pruned subtree). u1 itself was dropped in this + // compress, but the summary records the relationship so the + // user can correlate. + assert_eq!(summary.parent_id, u1_id); + assert_eq!(summary.message_count, 2); + // Preview pulls from the root sibling's content + label. + assert!( + summary.preview.contains("explore-alt") + || summary.preview.contains("different approach"), + "preview missing branch info: {:?}", + summary.preview, + ); + } + + /// `reset_to_new` clears `branch_summaries`. + #[test] + fn reset_to_new_clears_branch_summaries() { + let mut s = Session::new("p", "m", 0); + s.branch_summaries.push(BranchSummary { + root_id: CompactString::from("x"), + parent_id: CompactString::from("y"), + message_count: 3, + preview: "lingering branch".to_string(), + created_at: "2026-01-01".to_string(), + }); + s.reset_to_new(None); + assert!(s.branch_summaries.is_empty()); + } + /// Phase 3 — tool calls round-trip through serde with default /// for back-compat. Old session files without the field /// deserialize into an empty Vec. diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 30973e5a..26847509 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -3339,6 +3339,71 @@ fn rewind_session( } } let pruned_siblings = to_prune.len().saturating_sub(dropped_set.len()); + + // Phase 4: capture BranchSummary entries for each pruned + // sibling subtree BEFORE removing nodes. Same algorithm as + // `Session::compress_reporting` — root of a subtree is a + // node in `to_prune` whose direct parent was in + // `dropped_set` (the closest dropped-path ancestor). One + // summary per subtree root, walking descendants for the + // count. + let now_rfc = chrono::Utc::now().to_rfc3339(); + let mut subtree_summaries: Vec = Vec::new(); + for id in &to_prune { + if dropped_set.contains(id) { + continue; + } + let node = match session.tree.entries.get(id) { + Some(n) => n, + None => continue, + }; + let parent = match &node.parent { + Some(p) => p, + None => continue, + }; + if !dropped_set.contains(parent) { + continue; + } + let mut count = 0usize; + let mut stack = vec![id.clone()]; + while let Some(cur) = stack.pop() { + if !to_prune.contains(&cur) { + continue; + } + count += 1; + for (child_id, child_node) in session.tree.entries.iter() { + if child_node.parent.as_ref() == Some(&cur) { + stack.push(child_id.clone()); + } + } + } + let label_prefix = node + .label + .as_deref() + .map(|l| format!("[{}] ", l)) + .unwrap_or_default(); + let body_preview = session + .message_store + .get(id) + .map(|m| { + let s: String = m.content.chars().take(80).collect(); + if m.content.chars().count() > 80 { + format!("{}…", s) + } else { + s + } + }) + .unwrap_or_default(); + subtree_summaries.push(crate::session::BranchSummary { + root_id: id.clone(), + parent_id: parent.clone(), + message_count: count, + preview: format!("{}{}", label_prefix, body_preview), + created_at: now_rfc.clone(), + }); + } + session.branch_summaries.extend(subtree_summaries); + for id in &to_prune { session.tree.entries.remove(id); session.message_store.remove(id); diff --git a/src/ui/tree.rs b/src/ui/tree.rs index bb6e070b..96683650 100644 --- a/src/ui/tree.rs +++ b/src/ui/tree.rs @@ -66,6 +66,29 @@ pub fn render_tree(session: &Session) -> Vec { for root in roots { render_subtree(session, root, 0, leaf, &mut out); } + // Phase 4: append a "Summarized branches" section if any + // forked subtrees were pruned during prior compress / rewind. + // Each entry shows the parent id (which may itself be pruned by + // now), the count, and the preview captured at prune time. + // Without this, users only see "discarded N branches" in the + // moment but lose access to what was in them — defeats the + // point of pi-style preservation. + if !session.branch_summaries.is_empty() { + out.push(String::new()); + out.push(format!( + "Summarized branches ({}): pruned during compress/rewind", + session.branch_summaries.len(), + )); + for bs in &session.branch_summaries { + out.push(format!( + " └─ parent {} · {} msg{} · {}", + short_id(&bs.parent_id), + bs.message_count, + if bs.message_count == 1 { "" } else { "s" }, + bs.preview, + )); + } + } out }